feat(table): cursor keyset continuation in ListPaged#56
Conversation
1332d0a to
f8f081c
Compare
| // direction (empty means Asc; any other non-Asc/Desc value is an error). | ||
| // Forward-only and stable under concurrent writes, unlike ListPaged; IDColumn | ||
| // must be unique for the ordering to be stable. | ||
| func (t *Table[T, P, I]) ListCursor(ctx context.Context, where sq.Sqlizer, page *Page, order Order) ([]P, *Page, error) { |
There was a problem hiding this comment.
- Why do we need additional method?
- Why can't we have
ListPagedhandle page differently depending onPaginator? (page based / cursor based) - What happens if you pass cursor page to ListPaged? What happens if you pass page based page to ListCursor?
I believe the complexity may be hidden here. We do not need to expose user to this decision making and costly mistakes.
There was a problem hiding this comment.
Reworked: ListCursor is gone and ListPaged now handles both modes (c0cc5f5). Taking the questions in order:
1 + 2. Agreed on one method, with one change to the mechanism: dispatch keys on the request (the Page), not on the table's Paginator config. Config-based dispatch makes the same Page mean different things on differently-configured tables — Page: 5 against a cursor-configured table either silently becomes forward-only or errors at runtime — which is exactly the costly-mistake class this should kill. It also doesn't compose: CursorPaginator's cursor type can't see instance state like t.IDColumn. So: a Page carrying a Cursor continues a keyset walk over IDColumn; anything else is offset pagination, and id-ordered offset pages (the default order) now return NextCursor. Callers start with a plain Page{Size: n} and continue by cursor — no mode to pick, no new method.
3. Both crossings are now well-defined: a cursor page just works; a page-number page works and also hands back NextCursor. The ambiguous combination (Cursor + Page > 1) returns ErrCursorPaged instead of guessing, an order that contradicts the cursor returns ErrCursorPageOrdered, and the cursor encodes its own direction — validated strictly on decode, so a forged or corrupted token ("order":"sideways") returns ErrInvalidCursor rather than silently walking Asc. For reference, master has the silent case today: ListPaged ignores Page.Cursor and offset-paginates it.
PR description is updated to match.
f8f081c to
f753317
Compare
f753317 to
574c38c
Compare
- Order.IsValid() - strict check (exactly Asc/Desc) - Order.Sanitize() - lenient normalize (case + whitespace, unknown -> Asc); the Order type owns its own normalization rules - Sort.sanitize delegates direction handling to Order.Sanitize (was an inline switch); behavior identical - Page.SetDefaults zero-checks collapse to cmp.Or
- a Page carrying a Cursor continues a keyset walk over IDColumn: forward-only, stable under concurrent writes; anything else is offset-paginated as before - offset pages ordered exactly by IDColumn (the default) populate NextCursor, so callers start with a plain Page and continue by cursor - the cursor encodes its direction; a forged or missing direction in the token returns ErrInvalidCursor (cursors are minted, not user input), a conflicting page order returns ErrCursorPageOrdered, and cursor + page number > 1 returns ErrCursorPaged - PrepareResult owns resetting NextCursor (both paginators), so round-tripping the returned Page object never leaks a stale cursor; fixes the same leak in CursorPaginator.PrepareResult from #55
574c38c to
c0cc5f5
Compare
CursorPaginator(#55) gives keyset pagination but makes every consumer hand-write aCursortype —Apply/From/OrderBy— even for the overwhelmingly common case of "page by the table's id." This PR folds that case intoListPageditself, so callers never pick a pagination mode: aPagecarrying aCursorcontinues a keyset walk over the table'sIDColumn, anything else is offset pagination, and id-ordered offset pages hand back aNextCursorto opt in.(Earlier revision exposed this as a separate
ListCursormethod — reworked per review so the method choice, and the mixed-Pagemistakes it invited, no longer exist.)refactor(page)—Order.IsValid/Order.SanitizeOrder.IsValid()— strict check (exactlyAsc/Desc).Order.Sanitize()— lenient normalize (case + whitespace, unknown →Asc); theOrdertype now owns its own normalization rules.Sort.sanitizedelegates direction handling to it (was an inlineswitch); behavior-identical, still case-insensitive and defaulting.Page.SetDefaultszero-checks collapse tocmp.Or.feat(table)— cursor keyset continuation inListPagedSignature is unchanged:
Semantics:
IDColumn(the default when no sort is given), pages with more rows populatepage.NextCursor.Pagecarrying thatCursorcontinues as a keyset walk overIDColumn: forward-only, no random page access, but pages never skip or duplicate rows under concurrent writes. The first page of a walk is identical in both modes, so callers start with a plainPage{Size: n}and continue withPage{Size: n, Cursor: prev.NextCursor}— or just round-trip the returned*Page(More/NextCursorare reset every call, so reuse is safe). ResettingNextCursorlives inPrepareResult— the method that owns the page's output fields — which also fixes a latent leak inCursorPaginator.PrepareResultfrom feat: CursorPaginator for keyset pagination #55: it previously left a reused page's staleNextCursorin place on the final page.{"id": ..., "order": "DESC"}, opaque base64-JSON viaEncodeCursor), so a bare{Cursor: next}page cannot silently flip a Desc walk to Asc. Direction is chosen on the first page through the normal order vocabulary (Sort: [{id DESC}]/Column: "-id").ErrCursorPageOrdered(from feat: CursorPaginator for keyset pagination #55)Page > 1→ErrCursorPaged(new sentinel;Page == 1stays allowed becausePrepareResultwrites it into round-tripped pages)ASC/DESC→ErrInvalidCursor. Cursors are minted byListPaged, not user input — a forged"order":"sideways"is rejected, not coerced toAscthe waySortinput is.Notes
ListPaged: it previously ignoredPage.Cursor; now it acts on it, and it populatesNextCursorit never set before. The old behavior was itself a hazard — aPagecarrying a cursor was silently offset-paginated.Tableis markedNOTICE: Experimental, so no compatibility ceremony.Pageand not on the table'sPaginatorconfig: config-based dispatch makes the samePagemean different things on differently-configured tables (Page: 5against a cursor-configured table must either silently become forward-only or error at runtime), andCursorPaginator's cursor type can't see instance state liket.IDColumnanyway. Keying on the request keeps it self-describing.IDColumnto be unique — true for a primary key. Keyset by non-id columns stays out of scope (needs a forced tiebreaker);CursorPaginatorcovers that case.Test plan
TestTableListPagedCursor(tests/cursor_test.go):Lt/Gtbranches plus the offset→keysetNextCursorhandoff).*Pageobject across the whole walk and asserts the final page leaks no stale cursor;TestCursorPaginatorPaginateReturnsPagegot the same final-page assertion for theCursorPaginatorfix (verified red without the fix, green with it).ErrCursorPageOrdered, both wrong direction and wrong column), cursor + page number (ErrCursorPaged), undecodable cursor and forged cursor order —"sideways", lowercase, empty — (ErrInvalidCursor, verified red without the validation); non-id ordering emits no cursor.make test-all, 6 packages ok, 0 failures);go build/go vet/gofmt -lclean.