Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +50 XP

Пагинация и сортировка — Offset/Limit, Order и keyset-пагинация в GORM

Шестнадцатый урок закрыл жизненный цикл записи — теперь честно разделаемся с чтением больших таблиц. gorm пагинация — это три клоза: Limit и Offset режут выборку на страницы, Order задаёт порядок, Count отвечает на вопрос «сколько всего»; поверх них живут keyset-пагинация курсором и метка HasNext через LIMIT n+1, которые экономят и базу, и нервы. В этом уроке — механика clause.Limit из chainable_api.go (порядок Limit/Offset не важен, отмены через минус единицу), накопление Order и его инъекционная подноготная, источниковая правда о Count, страницы page/pageSize, собственный keyset-запрос и скоп Paginate из доки. Одна честная оговорка о формате: GORM требует рядом настоящую СУБД, в браузерной песочнице её не поставить, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а SQL-следы рассчитаны на локальный Go с PostgreSQL или SQLite.

Содержание урока · 14 разделов

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.

// страница по номеру: page/pageSize -> offset, метка «есть ещё» func pageOf(db *gorm.DB, page, pageSize int) ([]TodoItem, bool, error) { if page <= 0 { page = 1 // первый дефолт из доки } if pageSize <= 0 || pageSize > { pageSize = // второй дефолт из доки } var items []TodoItem err := db.Order("id"). Offset(( - 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 }

Соберите полный цикл offset-страницы

Расставьте шаги от параметров запроса до метки «есть ещё» — без пропущенных этапов.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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 уроков — на странице курса. Домашка ниже гоняет страницы по канону от первой до последней.

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

Термин

Limit и Offset внутри GORM

нажмите, чтобы перевернуть
Определение

Оба пишут в один клоз clause.Limit{Limit *int; Offset int} с именем «LIMIT» (clause/limit.go): Limit() -> AddClause(clause.Limit{Limit: &limit}), Offset() -> AddClause(clause.Limit{Offset: offset}). MergeClause объединяет поля, поэтому порядок вызовов не важен. Build печатает LIMIT при значении >= 0 и OFFSET при > 0. Find без Limit клоз не добавляет вовсе.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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, и запишите в комментарий, где бы вы поставили индекс.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Пагинация и сортировка — Offset/Limit, Order и keyset-пагинация в GORM)

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

Чем 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 добавляет кавычки диалекта, но существование колонки не проверяет — имя всё равно сверяйте со схемой.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx