Add transport-neutral keyset pagination through native Bob query mods. Preserve supported cursor value types in a compact versioned format, with bidirectional pages, explicit row keys, and a separate opt-in count. Include SQLite tests, PostgreSQL service CI, and the cursor-format ADR. Closes #6
185 lines
6.1 KiB
Go
185 lines
6.1 KiB
Go
// 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
|
|
}
|