Skip to content

feat(table): ListCursor for id-keyset pagination#56

Open
klaidliadon wants to merge 2 commits into
masterfrom
feat/table-list-cursor
Open

feat(table): ListCursor for id-keyset pagination#56
klaidliadon wants to merge 2 commits into
masterfrom
feat/table-list-cursor

Conversation

@klaidliadon

@klaidliadon klaidliadon commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

CursorPaginator (#55) gives keyset pagination but makes every consumer hand-write a Cursor type — Apply/From/OrderBy — even for the overwhelmingly common case of "page by the table's id." ListCursor is the ergonomic sibling of ListPaged for that case: it keys on the table's own IDColumn and GetID(), so callers get concurrency-stable, forward-only pagination with one method call and no boilerplate.

Two commits: a small Order refactor the feature builds on, then the feature.


refactor(page)Order.IsValid / Order.Sanitize

  • Order.IsValid() — strict check (exactly Asc/Desc).
  • Order.Sanitize() — lenient normalize (case + whitespace, unknown → Asc); the Order type now owns its own normalization rules.
  • Sort.sanitize delegates direction handling to Order.Sanitize() (was an inline switch); behavior-identical, still case-insensitive and defaulting.
  • Page.SetDefaults zero-checks collapse to cmp.Or.

feat(table)ListCursor

func (t *Table[T, P, I]) ListCursor(ctx context.Context, where sq.Sqlizer, page *Page, order Order) ([]P, *Page, error)
  • Orders by IDColumn in the caller-chosen direction. Empty order defaults to Asc (cmp.Or); any other non-Asc/Desc value is rejected via IsValid — a bad direction here is a caller bug, and silently paginating the wrong way is a worse failure than an error.
  • Reuses the table's Paginator size settings (SetDefaults) and EncodeCursor/DecodeCursor + ErrInvalidCursor.
  • Cursor payload is a method-local type ({"id": <value>}) keyed on GetID() — opaque base64-JSON, no new package-level symbol.
  • LIMIT n+1 to detect More without a second round-trip, same as Paginator/CursorPaginator.

Notes

  • Forward-only by design. Unlike ListPaged there's no random page access; in exchange pages never skip or duplicate rows under concurrent writes.
  • Uniqueness caveat. Keyset stability requires IDColumn to be unique — true for a primary key. Deliberately scoped to the id column; ordering by a non-unique column would need a forced tiebreaker, out of scope here.
  • Strictness asymmetry, on purpose. ListCursor rejects an invalid order while Sort.sanitize coerces one — because Sort's input is often user-facing free text (?sort=-created_at) that must degrade gracefully, whereas ListCursor's order is a typed developer argument where garbage is a bug.
  • Sits on Table, already marked NOTICE: Experimental, so no compatibility ceremony.

Test plan

Added TestTableListCursor (tests/cursor_test.go):

$ make test TEST=TestTableListCursor
--- PASS: TestTableListCursor (0.01s)
    --- PASS: TestTableListCursor/Desc_walks_newest_first_without_gaps_or_overlap (0.00s)
    --- PASS: TestTableListCursor/Asc_walks_oldest_first_without_gaps_or_overlap (0.00s)
    --- PASS: TestTableListCursor/empty_order_defaults_to_Asc (0.00s)
    --- PASS: TestTableListCursor/rejects_an_invalid_order (0.00s)
    --- PASS: TestTableListCursor/rejects_an_undecodable_cursor (0.00s)
PASS
ok  	github.com/goware/pgkit/v2/tests
  • Desc walks newest-first, Asc oldest-first, each asserting strict monotonic ordering and no gaps/overlap across all pages (covers both the Lt and Gt branches plus More/NextCursor transitions).
  • Empty order matches Asc; an invalid order and an undecodable cursor both error (the latter is ErrInvalidCursor).
  • Each commit builds standalone; full suite green (make test-all, 0 failures); go build/go vet/gofmt -l clean.

- add Order.IsValid (strict check) and Order.Sanitize (case/whitespace
  normalize, unknown -> Asc) so the Order type owns its own rules
- Sort.sanitize now delegates direction handling to Order.Sanitize
- collapse Page.SetDefaults zero-checks with cmp.Or
- add Table.ListCursor, the forward-only keyset sibling of ListPaged,
  ordering by IDColumn in a caller-chosen direction (empty -> Asc, any
  other non-Asc/Desc value is an error via Order.IsValid)
- reuse the table's Paginator size settings and EncodeCursor/DecodeCursor;
  cursor keys on IDColumn via GetID, opaque base64-JSON payload scoped as
  a local type inside the method
- ergonomic layer over CursorPaginator for the common single-id keyset
  case, so callers skip hand-writing Apply/From/OrderBy
@klaidliadon klaidliadon force-pushed the feat/table-list-cursor branch from 1332d0a to f8f081c Compare June 11, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant