Содержание урока · 14 разделов
- gorm пагинация: LIMIT, OFFSET и сортировка как каркас выборки
- clause.Limit: один клоз на двоих
- Order: накопление клозов, Reorder и Expression
- Инъекция через Order: строка без кавычек
- Count: сколько всего, прежде чем резать
- Страницы: page/pageSize -> offset
- keyset-пагинация: WHERE id < ? ORDER BY id DESC
- HasNext через LIMIT n+1
- Paginate: скоп из доки и дженерик-хелпер
- Чек-лист урока
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gorm пагинация: LIMIT, OFFSET и сортировка как каркас выборки#
Живой сервис редко показывает таблицу целиком: лента задач, история заказов, лог событий — всё это отдаётся страницами, и размер страницы выбираете вы, а не база. В SQL за это отвечают три клоза: LIMIT ограничивает число строк, OFFSET пропускает начало, ORDER BY фиксирует порядок — без него «страница» вообще не определена, база вправе вернуть строки в любом порядке. GORM оборачивает всё тремя методами цепочки, и дока описывает их без лишних слов: Limit — specify the max number of records to retrieve («задать максимум строк»), Offset — specify the number of records to skip before starting to return the records («сколько строк пропустить перед выдачей»), Order — specify order when retrieving records from the database. Шестой урок уже трогал Limit(1) внутри First — здесь разберём пару Limit/Offset как самостоятельный инструмент: внутренности clause.Limit, отмены условий, сортировку по нескольким полям, цену OFFSET на глубоких страницах и заменяющий её keyset-курсор.
Limit и Offset: первые страницы канона
GO// canon из пятого урока: пять задач в todo_items, id 1-5
var fresh []TodoItem
db.Limit(2).Order("id").Find(&fresh)
// SELECT * FROM todo_items ORDER BY id LIMIT 2
// доковский пример db.Limit(3).Find(&users) -> SELECT * FROM users LIMIT 3
var chunk []TodoItem
db.Order("id").Offset(2).Limit(2).Find(&chunk)
// SELECT * FROM todo_items ORDER BY id LIMIT 2 OFFSET 2
// пропустили 2 строки, взяли 2: задачи 3 и 4
// Cancel limit condition with -1: одна цепочка, повторный Find
var a, b, c, d []TodoItem
db.Limit(10).Find(&a).Limit(-1).Find(&b) // a: LIMIT 10, b: без LIMIT
// Cancel offset condition with -1:
db.Offset(10).Find(&c).Offset(-1).Find(&d) // c: OFFSET 10, d: без OFFSET
// одна запись по смещению - First из шестого урока делает это через
// Limit(1) и свою сортировку по ключуСтраница — это всегда пара «пропустить» и «взять»: OFFSET 2 плюс LIMIT 2 при сортировке по id дают задачи 3 и 4. Отмены через минус единицу — не экзотика, а приём из доки: одна цепочка, повторный Find, и второй запрос едет уже без клоза. И напоминание о ловушке chaining из шестого урока: условия живут на цепочке, поэтому «следующий запрос» на той же цепочке переиспользует всё, что вы навесили раньше.
clause.Limit: один клоз на двоих#
Сверим chainable_api.go. Limit(limit int) — это одна строка: tx.Statement.AddClause(clause.Limit{Limit: &limit}); Offset(offset int) — тоже одна: tx.Statement.AddClause(clause.Limit{Offset: offset}). Оба метода пишут в один и тот же клоз: в clause/limit.go у структуры clause.Limit{Limit int; Offset int} метод Name() возвращает «LIMIT», так что оба вызова дополняют одно место в запросе, а не создают два клоза. Слияние делает MergeClause: новый лимит перетирает старый, если задан; новый offset перетирает старый, если ненулевой; отрицательный offset обнуляется. Отсюда свойство, которое стоит запомнить как таблицу умножения: порядок Limit и Offset в цепочке не важен — db.Limit(10).Offset(5) и db.Offset(5).Limit(10) дают идентичный клоз. Доковский след «SELECT FROM users OFFSET 5 LIMIT 10» описывает семантику, а Build в clause/limit.go печатает сначала LIMIT, потом OFFSET — порядок клозов в тексте запроса; PostgreSQL принимает оба, MySQL требует LIMIT первым. Кому нужна байтовая точность следа — смотрите на логгер своего диалекта. Отмена через минус единицу работает по той же схеме: MergeClause сохраняет старое значение только для nil и нуля, минус единица проходит дальше, а Build печатает LIMIT лишь при значении не меньше нуля и OFFSET — при положительном. Кто ищет по запросу limit offset gorm, обычно спотыкается ровно здесь: хочет «отменить» лимит нулём, а ноль в цепочке означает «не трогать».
Проверка: порядок Limit и Offset в цепочке
db.Offset(6).Limit(3).Find(&todos) и db.Limit(3).Offset(6).Find(&todos) — что уедет в базу?
Что напечатает окно выборки
Прочитайте код и выберите, что он выведет:
var page []TodoItem
db.Order("id").Offset(2).Limit(2).Find(&page)
fmt.Println(len(page), page[0].ID, page[len(page)-1].ID)Order: накопление клозов, Reorder и Expression#
Дока формулирует предмет одной строкой: Specify order when retrieving records from the database. Строковая форма самая частая: db.Order("age desc, name").Find(&users) даёт ORDER BY age desc, name — несколько полей и направлений в одной строке через запятую. Вторая доковская форма — цепочка из нескольких Order: db.Order("age desc").Order("name").Find(&users) даёт тот же ORDER BY age desc, name: клозы накапливаются. Механика — в clause/order_by.go: MergeClause приклеивает новые колонки к уже накопленным в порядке вызовов, а флаг Reorder у OrderByColumn обрубает накопленное и начинает список заново — пригодится в скопах, когда базовый порядок надо заменить, а не дополнить. Третья форма — Expression: доковский пример с clause.OrderBy{Expression: clause.Expr{SQL: "FIELD(id,?)", Vars: []interface{}{[]int{1, 2, 3}}, WithoutParentheses: true}} сортирует строки в заданном порядке значений — ORDER BY FIELD(id,1,2,3). Пустая строка игнорируется: Order("") не добавит клоза, в chainable_api.go стоит проверка if v != "". И связка, которую стоит знать заранее: First и Last из finisher_api.go дописывают свою сортировку по первичному ключу в конец вашего списка — ваша колонка выигрывает, ключ лишь ломает ничьи между равными.
Сортировка по нескольким полям: три формы
GO// форма 1: цепочка Order - клозы накапливаются в порядке вызовов
db.Order("priority DESC").Order("created_at ASC").Order("id").Find(&todos)
// ORDER BY priority DESC, created_at ASC, id
// форма 2: одна строка через запятую
db.Order("priority DESC, created_at ASC, id").Find(&todos)
// форма 3: колонки структурой - имена в кавычках диалекта
db.Order(clause.OrderByColumn{Column: clause.Column{Name: "priority"}, Desc: true}).
Order(clause.OrderBy{Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: "created_at"}},
{Column: clause.Column{Name: "id"}},
}}).Find(&todos)
// Reorder: обрубить накопленное и начать список заново
db.Order("priority DESC").
Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Reorder: true}).
Find(&todos) // ORDER BY id - только онСортировка по нескольким полям — не украшение: без полного порядка строки с равными ключами идут в неопределённом порядке, и страницы прыгают от запроса к запросу. Форма со структурами проходит клоз через WriteQuoted — имена колонок попадают в SQL в кавычках диалекта, что делает её безопасным выбором, когда колонка приходит извне. Строковая форма уезжает буквально, и об этом — следующий блок.
Инъекция через Order: строка без кавычек#
Седьмой урок ввёл правило: пользовательский ввод — только через аргументы, а для конечных множеств — белые списки; там же перечислены методы, чьи строковые аргументы не экранируются, и Order в их числе. Теперь источник этой правды виден буквально: в chainable_api.go строковая форма оборачивается в clause.Column{Name: v, Raw: true} — Raw означает, что строка будет вписана в SQL как есть, без кавычек и без экранирования. Вклеили туда пользовательский sort=created_at с мусором — получили инъекцию через сортировку: ORDER BY вычисляется для каждой строки результата, и через условные подзапросы из него вытаскивают данные не хуже, чем через WHERE. Параметр сортировки из API — типичная ловушка: «сортировать по колонке X по убыванию» звучит как запрос строки, но строкой его отдавать нельзя. Безопасный путь — белый список: карта разрешённых колонок, направление отдельным списком ASC/DESC, и только после проверки собирать Order из констант. Структурная форма OrderByColumn добавляет кавычки через WriteQuoted, но не проверяет существование колонки — имя всё равно сверяйте со схемой. Тот же принцип действует для Group и Select: меняется клоз, правило нет.
Проверка: безопасная сортировка из параметра API
API принимает ?sort=priority&dir=desc. Как безопасно передать это в Order?
Count: сколько всего, прежде чем резать#
Дока отвечает на вопрос «зачем» сразу: The Count method in GORM is used to retrieve the number of records that match a given query — число записей, подходящих под запрос (gorm.io/docs/advanced_query.html). Внутри finisher_api.go Count(&count) делает три вещи, о которых стоит знать. Первая: если своего SELECT нет, клоз временно подменяется на count() — доковские комментарии показывают SELECT count(1) FROM users, а в исходниках дефолтное выражение count(); для счёта это одно и то же число. Вторая: ORDER BY при подсчёте выбрасывается — если клоза ORDER BY есть и GROUP BY нет, Count удаляет её, выполняет запрос и возвращает на место через defer; сортировать строки, которые вы лишь считаете, — лишняя работа для базы. Третья: с GROUP BY, а также во всех случаях, когда вернулось не ровно одна строка, в count записывается RowsAffected — доковский пример с Group("name") на четырёх пользователях с тремя разными именами даёт count => 3. Distinct работает: Distinct("name").Count(&count) даёт COUNT(DISTINCT(...)). Парного финишера «посчитай и отдай страницу» одним вызовом в ядре нет — grep по исходникам пуст; в первой версии библиотеки существовал FindAndCount, во второй ветке пара Count + Find собирается руками. Цена вопроса: каждый Count — отдельный полный проход по индексу, платить его на каждый клик по страницам не обязательно — об этом ниже.
Пара Count + Find на одинаковых фильтрах
GO// фабрика: две СВЕЖИЕ цепочки с одинаковыми фильтрами
list := func() *gorm.DB {
return db.Model(&TodoItem{}).Where("done = ?", false)
}
var total int64
list().Count(&total)
// SELECT count(*) FROM todo_items WHERE done = false
pageSize := 2
var page []TodoItem
list().Order("id").Offset(4).Limit(pageSize).Find(&page)
// SELECT * FROM todo_items WHERE done = false ORDER BY id LIMIT 2 OFFSET 4
totalPages := (total + pageSize - 1) / pageSize // потолок деления
db.Model(&TodoItem{}).Distinct("status").Count(&total)
// SELECT COUNT(DISTINCT(status)) FROM todo_itemsФабрика-замыкание возвращает свежую цепочку на каждый вызов: Count и Find не делят Statement, и условия не удваиваются — тот же урок chaining из шестого урока, свёрнутый в три строки. ORDER BY в Count-запросе вы не увидите даже если навесите: клоз удалён и вернётся после. Формула (total + pageSize - 1) / pageSize — целочисленный потолок деления: пять задач по две — три страницы, последняя неполная.
Проверка: Count и ORDER BY
db.Model(&TodoItem{}).Order("created_at DESC").Count(&count) — что уедет в базу и что станет с сортировкой?
Страницы: page/pageSize -> offset#
Пользователь приходит с номером страницы, а не со смещением. Перевод — одна строка: offset := (page - 1) * pageSize — третья страница по десять записей начинается с OFFSET 20. Но номер страницы — данные из внешнего мира, им нужны границы. Доковский Paginate из scopes.html (там же: Refer to Pagination for details on how to make a paginator) ставит ровно три: page <= 0 -> 1, pageSize <= 0 -> 10 и потолок pageSize > 100 -> 100 — потолок одной строкой кода защищает базу от запроса page_size=1000000. Для интерфейса обычно нужен и total: totalPages := (total + pageSize - 1) / pageSize — целочисленный потолок деления, без float. Стоит понимать цену. OFFSET n просит базу прочитать и выбросить n строк: OFFSET 100000 на большой таблице — сто тысяч отсканированных строк ради десяти, и чем глубже страница, тем дороже поездка. Вторая беда — дрейф: вставка или удаление между запросами сдвигает все окна, и строки дублируются или пропадают при листании. Для глубокой ленты это лечится keyset-курсором — следующий блок; для админки с пятьюдесятью страницами offset достаточен.
Что напечатает третья страница
Прочитайте код и выберите, что он выведет:
// canon: в todo_items пять задач, id 1-5
var total int64
db.Model(&TodoItem{}).Count(&total)
pageSize := 2
page := 3
offset := (page - 1) * pageSize
var items []TodoItem
db.Order("id").Offset(offset).Limit(pageSize).Find(&items)
fmt.Println(total, offset, len(items), items[0].ID)keyset-пагинация: WHERE id < ? ORDER BY id DESC#
Keyset меняет вопрос: не «пропусти n строк», а «дай следующие n после курсора». Курсор — значение сортировочного ключа последней показанной строки, чаще всего первичный ключ. Лента с новыми сверху: db.Where("id < ?", lastID).Order("id DESC").Limit(n).Find(&feed) — строго меньше последнего показанного ключа, новые вперёд. Прямая хроника зеркальна: db.Where("id > ?", lastID).Order("id ASC").Limit(n).Find(&next) — та самая формула через WHERE id > ? ORDER BY id LIMIT n, которой шестнадцатый урок обещал пагинацию. Выигрыш двойной. Скорость: вместо сканирования и выбрасывания offset строк база делает поиск по индексу — логарифм на любой глубине страницы. Стабильность: окно привязано к ключу, а не к позиции, поэтому вставки и удаления между запросами не сдвигают страницы — дрейфа нет. Цена тоже честная. Произвольного доступа нет: «прыгнуть на страницу семь» нельзя, только «ещё». Total без отдельного Count не узнать. И сортировка обязана быть полной: при упорядочивании по created_at добавляйте тай-брейк по ключу — ORDER BY created_at DESC, id DESC, иначе строки с одинаковым временем будут теряться на границе курсора. Индекс, который делает такой поиск быстрым, — тема следующего урока.
Keyset курсором: две ленты канона
GO// лента: новые сверху, курсор - id последней показанной строки
var feed []TodoItem
lastID := uint(3)
db.Where("id < ?", lastID).Order("id DESC").Limit(2).Find(&feed)
// SELECT * FROM todo_items WHERE id < 3 ORDER BY id DESC LIMIT 2
// -> задачи 2 и 1
// прямая хроника: от старых к новым
var next []TodoItem
db.Where("id > ?", lastID).Order("id ASC").Limit(2).Find(&next)
// SELECT * FROM todo_items WHERE id > 3 ORDER BY id ASC LIMIT 2
// -> задачи 4 и 5
// сортировка по времени - только с тай-брейком по ключу
db.Where("created_at < ?", lastTime).
Order("created_at DESC").Order("id DESC").
Limit(2).Find(&feed)Курсор подставляется аргументом — плейсхолдер, а не строка: инъекция исключена даже при враждебном клиенте. Неравенства строгие намеренно: «строго меньше» и «строго больше» исключают повтор последней показанной строки на следующей странице. При сортировке по времени условие курсора должно повторять оба ключа — created_at и id, иначе строки с равным временем будут теряться на стыке страниц.
HasNext через LIMIT n+1#
Шестнадцатый урок обронил фразу про LIMIT n+1 — теперь развяжем. Метка «есть ещё» нужна почти каждой ленте, и первый порыв — посчитать total через Count. Но Count — отдельный полный проход по индексу на каждый запрос страницы, ради одного бита информации. Приём дешевле: попросите на одну строку больше, чем нужно, — Limit(n + 1), — и посмотрите на длину ответа. Вернулось n+1 строк — следующая страница существует; срежьте хвост items[:n] и не показывайте лишнее. Вернулось n или меньше — это была последняя страница. Работает одинаково с keyset (курсором для следующего запроса служит ключ строки n) и с offset-окнами (запасная строка лежит сразу за окном). Сам приём — не специфичное GORM-решение, а общий приём SQL, но он честно сочетается с методами, которые вы уже знаете: Limit(n + 1).Find. Когда Count всё же нужен — нумерованные страницы и подпись «найдено 1234»: там без total интерфейс не собрать, и цену прохода приходится платить; для бесконечных лент хватает HasNext. И не забудьте срезать запасную строку до сериализации — иначе клиент получит n+1 запись и сломает вёрстку.
feedPage: страница ленты с курсором
GO// страница ленты: ключ последней строки + метка «есть ещё» без COUNT
func feedPage(db *gorm.DB, afterID uint, n int) ([]TodoItem, uint, bool, error) {
var page []TodoItem
err := db.Where("id < ?", afterID).
Order("id DESC").
Limit(n + 1). // запасная строка - сигнал о следующей странице
Find(&page).Error
if err != nil {
return nil, 0, false, err
}
hasNext := len(page) > n
if hasNext {
page = page[:n] // срезаем служебную строку
}
var nextID uint
if len(page) > 0 {
nextID = page[len(page)-1].ID // курсор для следующего запроса
}
return page, nextID, hasNext, nil
}Три возвращаемых значения — ровно то, что нужно клиенту: строки, следующий курсор и метка «есть ещё». nextID берётся у последней показанной строки, поэтому ответ самодостаточен: клиент ничего не вычисляет сам. На пустой базе вернётся пустой срез с hasNext = false — отдельного случая «страница пуста» обрабатывать не надо.
Заполните пропуски: offset-страница с дефолтами из доки
Соберите три пропуска: потолок pageSize, дефолт pageSize и переменная номера страницы в формуле offset.
Соберите полный цикл offset-страницы
Расставьте шаги от параметров запроса до метки «есть ещё» — без пропущенных этапов.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: OFFSET без ORDER BY — окно не определено, база вправе вернуть строки в любом порядке, и страницы прыгают; пагинация всегда с сортировкой. Вторая: строка Order из пользовательского ввода — Raw: true, инъекция через сортировку; только белый список и константы. Третья: Count «съедает» ORDER BY из SQL-следа — клоз удалён на время подсчёта и вернётся после; след без сортировки после Count — оптимизация, а не поломка. Четвёртая: семантика нуля и минуса — Offset(0) не отменяет прошлый offset (нужен Offset(-1)), одиночный Limit(0) даёт LIMIT 0 и пустую выборку, а Limit(0) в цепочке не перетирает прошлый лимит. Пятая: переиспользованная цепочка — условия первого запроса утекают во второй (ловушка chaining из шестого урока); берите свежую цепочку от корня db или фабрику-замыкание, как в блоке про Count.
Paginate: скоп из доки и дженерик-хелпер#
Разбор page/pageSize хочется написать один раз и применять к любой модели. Для этого в GORM есть Scopes: chainable_api.go складывает функции-фильтры в Statement.scopes, и executeScopes применяет их к цепочке перед выполнением; полный разбор скоупов ждёт девятнадцатый урок, здесь — базово. Дока показывает канонический скоп Paginate (scopes.html#pagination): func Paginate(r http.Request) func(db gorm.DB) gorm.DB читает page и page_size из параметров URL, ставит дефолты и потолок, считает offset := (page - 1) pageSize и возвращает db.Offset(offset).Limit(pageSize). Применение — два вызова на разных моделях: db.Scopes(Paginate(r)).Find(&users) и db.Scopes(Paginate(r)).Find(&articles): один хелпер, ноль дублирования. Дженерик-обёртка FetchPage[T] — тот же приём, свёрнутый в функцию: типизированный срез, границы и HasNext через запасную строку внутри. В generics.go у новой цепочки gorm.G[T] Limit, Offset, Order и Count(ctx, column) есть из коробки — связка «страница плюс счёт» на типах. Выбор формы за вами: скоп — для цепочек, дженерик — для сервисных функций.
Paginate из доки и дженерик-фасад
GO// Paginate - скоп из доки: разбор page/page_size, дефолты и потолок
func Paginate(r *http.Request) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
if page <= 0 {
page = 1
}
pageSize, _ := strconv.Atoi(q.Get("page_size"))
switch {
case pageSize > 100:
pageSize = 100
case pageSize <= 0:
pageSize = 10
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// один скоп - любые модели:
// db.Scopes(Paginate(r)).Find(&users)
// db.Scopes(Paginate(r)).Find(&articles)
// дженерик-фасад: страница любого типа с меткой «есть ещё»
func FetchPage[T any](db *gorm.DB, page, pageSize int) ([]T, bool, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 10
}
var items []T
err := db.Offset((page - 1) * pageSize).
Limit(pageSize + 1).
Find(&items).Error
if err != nil {
return nil, false, err
}
hasNext := len(items) > pageSize
if hasNext {
items = items[:pageSize]
}
return items, hasNext, nil
}Тело Paginate — почти дословно из доки: разбор q.Get("page") и q.Get("page_size"), три границы в switch, формула offset. FetchPage[T] добавляет HasNext через запасную строку: вызывающий получает срез без служебной строки и метку «есть ещё». Передавайте в него цепочку с уже наложенными условиями — на один вызов одна цепочка, иначе условия удвоятся.
Чек-лист урока#
- Limit и Offset пишут в один клоз clause.Limit (имя LIMIT): порядок вызовов не важен, MergeClause объединяет поля
- отмены из доки: Limit(-1) снимает лимит, Offset(-1) снимает смещение; ноль в цепочке означает «не трогать», а одиночный Limit(0) — пустую выборку
- Order: строка накапливается в порядке вызовов, Reorder обрубает список, Expression — произвольное выражение; First/Last дописывают сортировку по ключу в конец
- строковая форма Order — Raw: true без экранирования: параметры сортировки из API только через белый список и константы; OrderByColumn кавычки ставит, существование колонки не проверяет
- Count: SELECT подменяется на count(*) и возвращается, ORDER BY (без GROUP BY) удаляется, Distinct и Group поддержаны; пары «count плюс страница» одним финишером нет
- страница: offset := (page - 1) * pageSize; границы page <= 0 -> 1, pageSize <= 0 -> 10, потолок 100; totalPages := (total + pageSize - 1) / pageSize
- OFFSET сканирует и выбрасывает пропускаемые строки и дрейфует при вставках; keyset — Where("id < ?", lastID).Order("id DESC").Limit(n) с курсором и тай-брейком по ключу
- HasNext: запросите n+1 строку и срежьте хвост — метка «есть ещё» без COUNT; курсор следующей страницы — ключ последней показанной строки
Что дальше по курсу#
Пагинация опирается на соседние темы курса. Ловушка chaining и финишеры разобраны в шестом уроке, правило белых списков для строковых клозов — в седьмом уроке про Where, конвейер, из которого растёт AfterFind у длинных выборок, — в шестнадцатом уроке про хуки. Дальше по маршруту: восемнадцатый урок «Индексы и производительность» покажет, какие индексы делают keyset и offset-выборки быстрыми и почему поиск по курсору без них теряет смысл; девятнадцатый урок «Скоупы и переиспользование запросов» развернёт Scopes, на которых построен Paginate. Карта из 25 уроков — на странице курса. Домашка ниже гоняет страницы по канону от первой до последней.
Карточки: пагинация и сортировка
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пагинация в GORM — три клоза: Limit и Offset режут окно (один клоз LIMIT, порядок вызовов не важен), Order задаёт полный порядок, Count отвечает «сколько всего».
- Отмены условий — Limit(-1) и Offset(-1); ноль в цепочке «не трогает» прежнее значение, одиночный Limit(0) даёт пустую выборку.
- Order накапливает колонки (Reorder — обрубить список), строковая форма уезжает в SQL без экранирования (Raw: true): параметры сортировки из API — только через белый список; OrderByColumn кавычки ставит, но колонку не проверяет.
- Count подменяет SELECT на count(*) и возвращает, выбрасывает ORDER BY без GROUP BY, поддерживает Distinct и Group; FindAndCount из первой версии во второй нет — пара Count + Find собирается руками.
- Страница: offset := (page - 1) * pageSize с границами из доки (page <= 0 -> 1, pageSize <= 0 -> 10, потолок 100); totalPages — целочисленный потолок деления.
- OFFSET дорог и дрейфует на глубоких страницах; keyset — Where("id < ?", lastID).Order("id DESC").Limit(n) с курсором и тай-брейком по ключу — ищет по индексу и стабилен.
- HasNext без COUNT: Limit(n + 1), срез items[:n], курсор — ключ последней строки; скоп Paginate из доки и дженерик FetchPage[T] собирают это в переиспользуемый хелпер.
Домашнее задание
Разверните канон локально и прогоните страницы по кругу. Шаг первый: напечатайте первые три страницы канона через Order("id").Offset(k*2).Limit(2) и сверьте следы — LIMIT и OFFSET в каждом запросе, порядок задач не меняется. Шаг второй: соберите фабрику-замыкание с фильтром done = false и парой Count + Find; проверьте по логгеру, что Count-запрос уехал без ORDER BY, даже если навесить сортировку на цепочку. Шаг третий: реализуйте pageOf с дефолтами из доки (page <= 0 -> 1, pageSize <= 0 -> 10, потолок 100) и запасной строкой HasNext; прогоните page = 0, page = -1 и pageSize = 0, 1000 — границы должны сработать молча. Шаг четвёртый: feedPage с курсором id < ? ORDER BY id DESC LIMIT n+1 — пройдите всю таблицу курсором от максимального ключа и убедитесь, что строки не дублируются, а последняя страница вернёт hasNext = false. Шаг пятый: подключите Paginate(r) из доки к двум моделям и сравните следы — LIMIT и OFFSET совпадают при одинаковых параметрах. Финал: на таблице с тысячей строк замерьте время OFFSET 100000 и keyset-запроса — разницу увидите даже на SQLite, и запишите в комментарий, где бы вы поставили индекс.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем keyset-пагинация лучше Offset/Limit и когда offset достаточен?
keyset не читает и не выбрасывает пропускаемые строки — база делает поиск по индексу от курсора, поэтому глубина страницы не влияет на цену; вставки и удаления не сдвигают окна, дрейфа нет. Offset прост и даёт произвольный доступ к любой странице, поэтому для админок с небольшим числом страниц и стабильных справочников он достаточен. Для бесконечных лент и глубоких выборок берите курсор, а сортировку делайте полной — с тай-брейком по первичному ключу.
Как посчитать общее число страниц и есть ли FindAndCount в GORM v2?
Число страниц — целочисленный потолок деления: totalPages := (total + pageSize - 1) / pageSize, где total получен Count по тем же фильтрам, что и выборка — удобно через фабрику-замыкание. Финишера FindAndCount из первой версии библиотеки во второй нет: пара Count + Find — это два запроса, и каждый Count стоит полного прохода по индексу. Если нужен только признак «есть ещё», дешевле запросить n+1 строку и срезать хвост.
Почему Order строкой опасен и как сортировать по параметру из запроса?
Строковая форма Order оборачивается в clause.Column с Raw: true — строка вписывается в SQL без кавычек и экранирования, плейсхолдеры там не работают. Параметр сортировки из API прогоняйте через белый список колонок и отдельный список направлений, а клоз собирайте из проверенных констант. Структурная OrderByColumn добавляет кавычки диалекта, но существование колонки не проверяет — имя всё равно сверяйте со схемой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx