pagination

package
v1.28.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: MIT Imports: 19 Imported by: 4

Documentation

Index

Constants

View Source
const (
	// MAX_PAGE_SIZE is the largest page; a larger limit is clamped to it.
	MAX_PAGE_SIZE = 1000
	// DEFAULT_LIMIT is the page size of a request without a limit.
	DEFAULT_LIMIT = 50
)

Variables

This section is empty.

Functions

func EncodeCursor

func EncodeCursor(c Cursor) (string, error)

EncodeCursor creates a base64 encoded cursor from a Cursor struct, signed when a signing key has been set with SetSigningKey

func SetSigningKey added in v1.16.0

func SetSigningKey(key []byte)

SetSigningKey enables HMAC-SHA256 signing of cursors so clients cannot forge pagination positions. Once a key is set, EncodeCursor appends a signature and DecodeCursor rejects cursors with a missing or invalid signature.

Enabling signing (or changing the key) invalidates every cursor issued before the key was set. Passing an empty key disables signing and restores unsigned cursors.

Types

type Cursor

type Cursor struct {
	SortField   string      `json:"sort_field"`
	SortValue   interface{} `json:"sort_value"`
	UniqueField string      `json:"unique_field"`
	UniqueValue interface{} `json:"unique_value"`
	Direction   string      `json:"direction,omitempty"`
}

Cursor holds the values needed for pagination. SortField and Direction pin the order the cursor was issued for, so it cannot be replayed under another sort. Cursors issued before Direction existed have it empty.

func DecodeCursor

func DecodeCursor(encodedCursor string) (*Cursor, error)

DecodeCursor decodes a base64 encoded cursor string into a Cursor struct, verifying its signature when a signing key has been set with SetSigningKey.

Numeric SortValue and UniqueValue are decoded without loss of precision: integers come back as int64 (so values above 2^53, such as snowflake IDs or unix nanoseconds, survive the round trip), integers above the int64 range as uint64 and other numbers as float64. Strings, bools and nil are returned unchanged. A value that is an object, an array, a number that fits none of these types or a string holding a NUL byte, which no Postgres text column can have produced and bun refuses to format, is ez.EINVALID.

type CursorRequest

type CursorRequest struct {
	Limit        int    `json:"limit"`
	Cursor       string `json:"cursor"`
	Sort         string `json:"sort,omitempty"`
	IncludeCount bool   `json:"include_count,omitempty"`

	// Filter is server-side only: Field and Comparison are interpolated into SQL and must never come from client input.
	Filter []query.Filter `json:"-" xml:"-"`
}

CursorRequest represents a cursor paginated request.

Limit, Cursor, Sort and IncludeCount come from the client. Filter is server-side only and never read from client input: it is hidden from the JSON and XML decoders echo binds bodies with.

Sort is one of the Options.SortFields keys, prefixed with "-" for descending ("-name"); see Options.SortFields. An empty Sort orders by the model's GetSortField in Options.Direction.

IncludeCount asks for a count, which Paginate runs only when the endpoint sets Options.AllowCount; see Options.AllowCount. The endpoint passes its Options to Paginate.

func (*CursorRequest) Validate

func (r *CursorRequest) Validate() error

Validate applies the default limit and clamps the limit to MAX_PAGE_SIZE. A negative limit is ez.EINVALID. It does not check Sort: the sort allowlist is an argument of Paginate, so Paginate, not Validate, resolves Sort.

type CursorResponse

type CursorResponse struct {
	NextCursor  string `json:"next_cursor,omitempty"`
	HasNextPage bool   `json:"has_next_page"`
	TotalCount  *int   `json:"total_count,omitempty"`
}

CursorResponse represents a cursor paginated response. Embed it in a list response next to the items; it marshals to next_cursor (omitted on the last page), has_next_page and total_count (only when the request set include_count on a list whose Options set AllowCount).

func Paginate added in v1.23.0

func Paginate[T Paginatable](ctx context.Context, q *bun.SelectQuery, r *CursorRequest, model T, opts Options) ([]T, CursorResponse, error)

Paginate runs one page of q, a select the caller has already scoped and filtered, and returns its items and the response to embed next to them.

It validates r, resolves the order, decodes the cursor and checks it was issued for that order, applies r.Filter, counts the filtered rows when r.IncludeCount is set (before the cursor, so the count covers the whole filtered set), applies the cursor, the order and the limit, scans and issues the next cursor. The order is r.Sort when set, resolved against opts.SortFields and rendered from the model's bun schema, otherwise model.GetSortField() in opts.Direction, with model.GetUniqueField() as the tie-breaker. A direction other than ASC or DESC is ez.EINTERNAL. A cursor issued for another sort field or direction is ez.EINVALID; a cursor without a direction predates Direction and was issued under the endpoint's default order, so it is held to opts.Direction.

The count is closed by default: r.IncludeCount without opts.AllowCount is ez.EINVALID, before any query.

A query error keeps its own code: an ez error keeps its code, context.Canceled is ez.ECANCELED, context.DeadlineExceeded or a network timeout is ez.ETIMEOUT, and any other error, a Postgres statement_timeout included, is ez.EINTERNAL. Whether and when a done context ends a query, and with which error, is up to database/sql and the driver.

q's top-level conditions must be joined by AND: put OR conditions inside WhereGroup(" AND ", ...). After a top-level OR, r.Filter and the cursor bind only to the last OR branch, so the cursor does not hold back rows matching the other branches: pages repeat them, and once they fill a page paging never ends. bun's soft-delete scope and WherePK stay outside the OR: bun wraps the query's own conditions in parentheses before adding them.

A query with Union is not supported: the cursor, the order and the limit cover only its first member, and a count fails or is wrong.

The count does not run the model's BeforeSelect hook, as bun's Count does not, so rows that hook filters out of the page are still counted. Such filtering belongs on q.

A client sort is rendered on ?TableAlias, so it needs q to have a Model; a client sort on a query without one is ez.EINTERNAL.

When q has Model(&items) with items a []T, T being the model argument's type, the rows are scanned into it, which keeps has-many relations working, and items is left holding the returned page. A query model that is a slice of another type, such as []*T, is ez.EINTERNAL. Without a slice model the rows are scanned into a new slice. q is modified in place, as bun queries are.

type Options added in v1.23.0

type Options struct {
	// Direction is the default order, used when the request has no Sort. A Direction other than ASC or DESC,
	// the zero value included, is ez.EINTERNAL, even under a client sort.
	Direction OrderDirection
	// SortFields is the allowlist of client sorts: sort name to a column of the model's table,
	// e.g. {"name": "name"}, rendered as ?TableAlias."name" from the model's bun schema. Empty means the
	// client cannot choose the sort. A client-sortable column must be NOT NULL and selected by the query: the
	// keyset comparison skips rows whose sort value is NULL, and a column the query does not select pages from
	// a zero value. Give each sortable column an index on (column, unique column) for large tables. A Go float
	// field sorts correctly only on a column no wider than its type, holding finite values: a float32 only on
	// real, a float64 on real or double precision. A wider column, double precision under a float32 or numeric
	// with more digits than the field holds, is rounded in the cursor, so pages repeat or skip rows and may
	// never end, and NaN or ±Infinity cannot be stored in a cursor, so the page fails with ez.EINTERNAL. Map a
	// numeric column to a string or decimal type implementing driver.Valuer instead.
	SortFields map[string]string
	// AllowCount lets the client ask for a count with IncludeCount. False means the client cannot ask for a
	// count. The count is a count(*) over the whole filtered query on every request that asks for it, so
	// enable it only on lists whose filtered set is bounded. The count does not run the model's BeforeSelect
	// hook, so rows that hook filters out of the page are still counted: filter on the query passed to
	// Paginate instead. A count renders the query twice, and bun runs the apply function of a has-one or
	// belongs-to relation on every render. What an apply function adds to the query it is given (conditions,
	// joins, columns) is not repeated in the page query. Two things can still change the page query when a
	// count is asked for, so the page can fail or disagree with the total: an apply function that calls
	// Relation with another apply function, or JoinOn on a join the query already had, and a relation with
	// an apply function inside a With query or a subquery. Do not enable the count on such a list.
	AllowCount bool
}

Options is the endpoint's server-side list configuration, passed to Paginate. None of it comes from the client.

type OrderDirection

type OrderDirection string

OrderDirection represents the allowed direction for pagination.

const (
	ASC  OrderDirection = "asc"
	DESC OrderDirection = "desc"
)

type Paginatable

type Paginatable interface {
	GetSortField() string        // Returns the SQL expression of the default sort, e.g. "?TableAlias.created_at"
	GetSortValue() interface{}   // Returns the value of the default sort field
	GetUniqueField() string      // Returns the SQL expression of the unique tie-breaker, e.g. "?TableAlias.id"
	GetUniqueValue() interface{} // Returns the value for the unique field
}

Paginatable interface that models must implement to use pagination

GetSortField and GetUniqueField are interpolated raw into SQL (so the default sort may be an expression like LENGTH(?TableAlias.name)), so they must return compile-time constants and never client input. GetSortField and GetSortValue serve the default sort, used when the request has no Sort; a client-chosen sort reads its value from the model's bun schema instead. Neither is called under a client sort, so a query sorted by the client need not select the default sort column. GetSortField's text is recorded in cursors and compared on replay, so changing its spelling rejects every cursor in flight. GetUniqueField's text is recorded in cursors too but not checked on replay, so changing it or its column's type leaves cursors in flight carrying values of the old one.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL