// Package page provides transport-neutral keyset pagination for Bob SELECT queries. // Callers own authorization, non-null stored sort values, and correspondence between // Sort columns and the typed row key. The final column must itself be unique in the // selected relation. Mixed directions are supported; null sort values are not. // // Sort IDs version the expressions, directions, key types and comparison semantics. // Cursors expose values and allow edits; they are seek boundaries, not credentials. // Concurrent writes or changed filters can skip or repeat rows between requests. // Code generation may eventually align Sort and key; handwritten pairs have no // compile-time field/type correspondence guarantee. // // Keys support exactly int, int8, int16, int32, int64, string, time.Time and the // standard uuid.UUID. Named types, unsigned values, pointers and null wrappers // require explicit caller conversion. Timestamps normalize to UTC with nanosecond // precision. Versioned JSON cursors use unpadded base64url, limited to 16,384 bytes // after encoding; unsupported values and malformed cursors return typed errors. package page import ( "fmt" "slices" "unicode/utf8" "github.com/stephenafamo/bob" ) // Order is a column's canonical ordering direction. type Order uint8 const ( // Asc orders smaller values first. Asc Order = iota // Desc orders larger values first. Desc ) // Column declares a trusted, non-null SQL expression and its canonical direction. // Unique asserts that this column alone is unique, including across joins. type Column struct { Expression bob.Expression Direction Order Unique bool } // Sort is an immutable, caller-owned ordering contract. Change ID when its // expressions, order, key types, uniqueness or comparison semantics change. type Sort struct { ID string Columns []Column } // Direction selects which side of an optional anchor to fetch. type Direction uint8 const ( // Forward maps to first/after and is the default direction. Forward Direction = iota // Backward maps to last/before; returned items retain canonical sort order. Backward ) // Request carries a transport-independent page request. Zero Size uses the // configured default; excessive sizes clamp to Maximum. Empty Cursor has no anchor. type Request struct { Direction Direction Size int Cursor string } // Limits configures page sizes; require 1 <= Default <= Maximum < max int. type Limits struct { Default int Maximum int } // KeyError identifies a programmer error in the row key's arity. type KeyError struct { Expected int Actual int } // Error implements error with the expected and actual key lengths. func (e *KeyError) Error() string { return fmt.Sprintf("page: key length %d, expected %d", e.Actual, e.Expected) } // Page contains canonical-order items and cursors for their first and last rows. // Empty pages have empty cursors. Overflow determines the fetch-direction flag; // anchor presence determines the opposite flag, even for empty/stale results. // The opposite flag does not prove another row exists. type Page[T any] struct { Items []T StartCursor string EndCursor string HasNext bool HasPrevious bool } // Prepared holds validated pagination and the typed row key. Construct with Prepare. // Treat Sort expressions and the key's captured state as immutable while in use. type Prepared[T any] struct { sort Sort key func(T) []any direction Direction size int anchor []any } // Prepare validates a request before a Bob mod can be applied. key must return // exactly one supported concrete value per Sort column, in the same order, using // actual stored/scanned values (including database timestamp precision). func Prepare[T any](sort Sort, key func(T) []any, request Request, limits Limits) (*Prepared[T], error) { if sort.ID == "" || !utf8.ValidString(sort.ID) || len(sort.Columns) == 0 { return nil, fmt.Errorf("page: sort needs a valid ID and columns") } for i, c := range sort.Columns { if c.Expression == nil || c.Direction > Desc { return nil, fmt.Errorf("page: invalid sort column %d", i) } } if !sort.Columns[len(sort.Columns)-1].Unique { return nil, fmt.Errorf("page: final sort column must be unique") } if key == nil { return nil, fmt.Errorf("page: nil key function") } if limits.Default < 1 || limits.Maximum < limits.Default || limits.Maximum == int(^uint(0)>>1) { return nil, fmt.Errorf("page: invalid limits") } if request.Direction > Backward || request.Size < 0 { return nil, fmt.Errorf("page: invalid request direction or size") } size := request.Size if size == 0 { size = limits.Default } size = min(size, limits.Maximum) sort.Columns = slices.Clone(sort.Columns) p := &Prepared[T]{sort: sort, key: key, direction: request.Direction, size: size} if request.Cursor != "" { var err error p.anchor, err = decodeCursor(request.Cursor, sort) if err != nil { return nil, err } } return p, nil } // Page validates all fetched rows, including overflow, and assembles a page. // Pass rows in query order, with at most size+1 rows. It copies retained items; // backward pagination never reverses the caller's slice. Errors return no page. func (p *Prepared[T]) Page(rows []T) (Page[T], error) { var zero Page[T] if len(rows) > p.size+1 { return zero, fmt.Errorf("page: too many fetched rows") } cursors := make([]string, len(rows)) for i, row := range rows { key := p.key(row) if len(key) != len(p.sort.Columns) { return zero, &KeyError{Expected: len(p.sort.Columns), Actual: len(key)} } var err error cursors[i], err = encodeCursor(p.sort.ID, key) if err != nil { return zero, err } } overflow := len(rows) > p.size n := min(len(rows), p.size) result := Page[T]{Items: slices.Clone(rows[:n])} if n > 0 { result.StartCursor, result.EndCursor = cursors[0], cursors[n-1] } if p.direction == Backward { slices.Reverse(result.Items) result.StartCursor, result.EndCursor = result.EndCursor, result.StartCursor result.HasPrevious, result.HasNext = overflow, len(p.anchor) > 0 } else { result.HasNext, result.HasPrevious = overflow, len(p.anchor) > 0 } return result, nil }