feat(page): add typed keyset pagination for Bob (#15)
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
This commit was merged in pull request #15.
This commit is contained in:
1 parent
e5298cff37
commit
c45e8abe08
11 files changed
+1225
-1
No files matched your search
+184
@@ -0,0 +1,184 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user