Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ var (
ErrCursorQueryOrdered = errors.New("cursor query already has order by")
// ErrCursorPageOrdered signals page-level ordering that does not match the cursor order.
ErrCursorPageOrdered = errors.New("cursor page order does not match cursor order")
// ErrCursorPaged signals a page carrying both a cursor and a page number.
ErrCursorPaged = errors.New("cursor and page number are mutually exclusive")
// ErrPageKindMismatch signals a page whose kind does not match the table's pagination mode.
ErrPageKindMismatch = errors.New("page kind does not match the table's pagination mode")
)

// EncodeCursor produces an opaque cursor: base64-JSON, not signed, never use it for authorization.
Expand Down Expand Up @@ -135,6 +139,7 @@ func (p CursorPaginator[T, C, PC]) PrepareResult(result []T, page *Page) ([]T, e
limit := int(page.Limit())
page.Size = uint32(limit)
page.More = len(result) > limit
page.NextCursor = ""
if !page.More {
return result, nil
}
Expand Down
73 changes: 51 additions & 22 deletions page.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pgkit

import (
"cmp"
"context"
"fmt"
"regexp"
Expand All @@ -25,6 +26,49 @@ const (
Asc Order = "ASC"
)

// PageKind classifies a Page by the fields it carries. It is validation input
// for Table.ListPaged, not a mode selector — the table's Mode picks the path.
type PageKind uint8

const (
EmptyPage PageKind = iota // neither a cursor nor an explicit page number
OffsetPage // an explicit page number (Page > 1), no cursor
CursorPage // a cursor, no page number
MixedPage // both a cursor and a page number — always invalid
)

// Kind classifies the page by its populated fields.
func (p *Page) Kind() PageKind {
if p == nil {
return EmptyPage
}
hasCursor, hasPage := p.Cursor != "", p.Page > 1
switch {
case hasCursor && hasPage:
return MixedPage
case hasCursor:
return CursorPage
case hasPage:
return OffsetPage
default:
return EmptyPage
}
}

// IsValid reports whether o is one of the defined sort directions.
func (o Order) IsValid() bool {
return o == Asc || o == Desc
}

// Sanitize normalizes case and surrounding whitespace, defaulting any unrecognized value to Asc.
func (o Order) Sanitize() Order {
o = Order(strings.ToUpper(strings.TrimSpace(string(o))))
if !o.IsValid() {
return Asc
}
return o
}

type Sort struct {
Column string
Order Order
Expand All @@ -39,14 +83,7 @@ func (s Sort) sanitize(columnFunc func(string) string) Sort {
s.Column = pgx.Identifier(strings.Split(s.Column, ".")).Sanitize()
}

switch strings.ToUpper(strings.TrimSpace(string(s.Order))) {
case string(Desc):
s.Order = Desc
case string(Asc):
s.Order = Asc
default:
s.Order = Asc
}
s.Order = s.Order.Sanitize()
return s
}

Expand Down Expand Up @@ -100,23 +137,13 @@ func (p *Page) SetDefaults(o *PaginatorSettings) {
if o == nil {
o = &PaginatorSettings{}
}
defaultSize := o.DefaultSize
if defaultSize == 0 {
defaultSize = DefaultPageSize
}
maxSize := o.MaxSize
if maxSize == 0 {
maxSize = MaxPageSize
}
if p.Size == 0 {
p.Size = defaultSize
}
defaultSize := cmp.Or(o.DefaultSize, DefaultPageSize)
maxSize := cmp.Or(o.MaxSize, MaxPageSize)
p.Size = cmp.Or(p.Size, defaultSize)
if p.Size > maxSize {
p.Size = maxSize
}
if p.Page == 0 {
p.Page = 1
}
p.Page = cmp.Or(p.Page, 1)
}

func (p *Page) GetOrder(columnFunc func(string) string, defaultSort ...string) []Sort {
Expand Down Expand Up @@ -303,6 +330,8 @@ func (p Paginator[T]) PrepareRaw(q string, args []any, page *Page) ([]T, string,
func (p Paginator[T]) PrepareResult(result []T, page *Page) []T {
limit := int(page.Limit())
page.More = len(result) > limit
// Offset pagination yields no cursor - clear any stale value from a reused page.
page.NextCursor = ""
if page.More {
result = result[:limit]
}
Expand Down
154 changes: 152 additions & 2 deletions table.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type Table[T any, P Record[T, I], I ID] struct {
Name string
IDColumn string
Paginator Paginator[P]
// Mode selects how ListPaged paginates. Zero value PageBased (offset).
Mode PaginatorKind
}

// HasSetCreatedAt is implemented by records that track creation time.
Expand Down Expand Up @@ -397,11 +399,64 @@ func (t *Table[T, P, I]) List(ctx context.Context, where sq.Sqlizer, orderBy []s
return records, nil
}

// ListPaged returns paginated records matching the condition.
// PaginatorKind selects a Table's pagination mode. The zero value is PageBased.
type PaginatorKind uint8

const (
PageBased PaginatorKind = iota // offset pagination (default)
CursorBased // keyset pagination over IDColumn
)

// idCursor is the keyset cursor a CursorBased table encodes; it carries its own
// direction so a bare {Cursor: next} page continues the walk.
type idCursor[I ID] struct {
ID I `json:"id"`
Order Order `json:"order"`
}

// ListPaged returns paginated records matching the condition, using the table's
// configured Mode — NOT the shape of the supplied page. A PageBased table
// offset-paginates; a CursorBased table keyset-paginates over IDColumn:
// forward-only, no random page access; unlike offset pagination it does not
// skip or duplicate rows when concurrent writes shift earlier pages (rows
// inserted ahead of the cursor are simply not revisited). The page is
// validated against the mode before any query runs: a cursor page to a
// PageBased table or a page number to a CursorBased table returns
// ErrPageKindMismatch, and a page carrying both a cursor and a page number
// returns ErrCursorPaged. IDColumn must be unique for the keyset ordering to
// be stable.
func (t *Table[T, P, I]) ListPaged(ctx context.Context, where sq.Sqlizer, page *Page) ([]P, *Page, error) {
if page == nil {
page = &Page{}
}
if err := t.validatePage(page); err != nil {
return nil, nil, err
}
if t.Mode == CursorBased {
return t.listKeyset(ctx, where, page)
}
return t.listOffset(ctx, where, page)
}

// validatePage rejects page/mode combinations that cannot be served, so a caller
// never silently gets the wrong pagination.
func (t *Table[T, P, I]) validatePage(page *Page) error {
switch page.Kind() {
case MixedPage:
return ErrCursorPaged
case CursorPage:
if t.Mode != CursorBased {
return ErrPageKindMismatch
}
case OffsetPage:
if t.Mode == CursorBased {
return ErrPageKindMismatch
}
}
return nil
}

func (t *Table[T, P, I]) listOffset(ctx context.Context, where sq.Sqlizer, page *Page) ([]P, *Page, error) {
// Ensure deterministic ordering for stable pagination.
if len(page.Sort) == 0 && page.Column == "" && len(t.Paginator.settings.Sort) == 0 {
page.Sort = []Sort{{Column: t.IDColumn, Order: Asc}}
Expand All @@ -412,7 +467,89 @@ func (t *Table[T, P, I]) ListPaged(ctx context.Context, where sq.Sqlizer, page *
if err := t.Query.GetAll(ctx, q, &result); err != nil {
return nil, nil, err
}
result = t.Paginator.PrepareResult(result, page)
return t.Paginator.PrepareResult(result, page), page, nil
}

// listKeyset serves a CursorBased table: a first page (no cursor) or a
// continuation (cursor). Direction comes from the cursor; a first page takes it
// from a supplied id-order, else a single id-column default sort, else Asc. A
// supplied page order is validated, never mutated (only a nil page is defaulted).
func (t *Table[T, P, I]) listKeyset(ctx context.Context, where sq.Sqlizer, page *Page) ([]P, *Page, error) {
order := Asc
var after *I
if page.Cursor != "" {
cursor, err := DecodeCursor[idCursor[I]](page.Cursor)
if err != nil {
return nil, nil, err
}
// The cursor is minted here with an exact direction - anything else is a
// forged or corrupted token, so reject it rather than coerce.
if !cursor.Order.IsValid() {
return nil, nil, ErrInvalidCursor
}
order, after = cursor.Order, &cursor.ID
}

// Both column comparisons go through ColumnFunc + identifier sanitizing, so a
// ColumnFunc that rewrites the id column cannot desync them.
idCol := Sort{Column: t.IDColumn}.sanitize(t.Paginator.settings.ColumnFunc).Column
// A page-supplied order is validated strictly. The table's default sort is
// consulted only on a first page, and only to pick the direction when it is a
// single id-column sort — non-id defaults are offset-mode config, ignored here.
switch sorts := page.GetOrder(t.Paginator.settings.ColumnFunc); {
case len(sorts) == 0:
if page.Cursor != "" {
break // continuation: the cursor owns the direction
}
if ds := page.GetOrder(t.Paginator.settings.ColumnFunc, t.Paginator.settings.Sort...); len(ds) == 1 && ds[0].Column == idCol {
order = ds[0].Order
}
case len(sorts) == 1 && sorts[0].Column == idCol:
if page.Cursor != "" && sorts[0].Order != order {
return nil, nil, ErrCursorPageOrdered // continuation: must match the cursor
}
if page.Cursor == "" {
order = sorts[0].Order // first page: caller picks direction
}
default:
return nil, nil, ErrCursorPageOrdered // keyset only orders by IDColumn
}

page.SetDefaults(&t.Paginator.settings)
page.Page = 0 // keyset pagination has no page number
page.More = false
page.NextCursor = ""

q := t.SQL.Select("*").From(t.Name).Where(where).
OrderBy(Sort{Column: t.IDColumn, Order: order}.String())
if after != nil {
if order == Desc {
q = q.Where(sq.Lt{t.IDColumn: *after})
} else {
q = q.Where(sq.Gt{t.IDColumn: *after})
}
}

limit := int(page.Limit())
q = q.Limit(uint64(limit) + 1)

result := make([]P, 0, limit+1)
if err := t.Query.GetAll(ctx, q, &result); err != nil {
return nil, nil, err
}

page.Size = uint32(limit)
if len(result) <= limit {
return result, page, nil
}
page.More = true
result = result[:limit]
next, err := EncodeCursor(idCursor[I]{ID: result[len(result)-1].GetID(), Order: order})
if err != nil {
return nil, nil, err
}
page.NextCursor = next

return result, page, nil
}

Expand Down Expand Up @@ -530,6 +667,18 @@ func (t *Table[T, P, I]) WithPaginator(opts ...PaginatorOption) *Table[T, P, I]
Name: t.Name,
IDColumn: t.IDColumn,
Paginator: NewPaginator[P](opts...),
Mode: t.Mode,
}
}

// WithMode returns a table instance using the given pagination mode.
func (t *Table[T, P, I]) WithMode(mode PaginatorKind) *Table[T, P, I] {
return &Table[T, P, I]{
DB: t.DB,
Name: t.Name,
IDColumn: t.IDColumn,
Paginator: t.Paginator,
Mode: mode,
}
}

Expand All @@ -544,6 +693,7 @@ func (t *Table[T, P, I]) WithTx(tx pgx.Tx) *Table[T, P, I] {
Name: t.Name,
IDColumn: t.IDColumn,
Paginator: t.Paginator,
Mode: t.Mode,
}
}

Expand Down
Loading
Loading