From b8308f358a270ff93e0f1d502ed95f2b5a7dba0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Fri, 12 Jun 2026 11:41:21 +0200 Subject: [PATCH 1/2] refactor(page): add Order.IsValid/Sanitize and use them - 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 --- page.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/page.go b/page.go index 3b99f55..367f735 100644 --- a/page.go +++ b/page.go @@ -1,6 +1,7 @@ package pgkit import ( + "cmp" "context" "fmt" "regexp" @@ -25,6 +26,20 @@ const ( Asc Order = "ASC" ) +// 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 @@ -39,14 +54,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 } @@ -100,23 +108,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 { @@ -303,6 +301,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] } From 28ad35c3cabec0531c71110b56b143a563479fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Fri, 12 Jun 2026 11:41:30 +0200 Subject: [PATCH 2/2] feat(table): table-mode cursor pagination via ListPaged - add Table.Mode (PaginatorKind; zero value PageBased) so a table declares its pagination mode, instead of ListPaged inferring it from the page shape - ListPaged dispatches on the table's Mode, not on page.Cursor. Page.Kind() classifies the supplied page and validatePage rejects mismatches up front: offset table + cursor page / cursor table + page number give ErrPageKindMismatch, cursor + page number gives ErrCursorPaged - CursorBased keyset-paginates over IDColumn: first page takes direction from a supplied id-order (default Asc), a continuation reads it from the cursor; a supplied page order is validated, never mutated - add WithMode; propagate Mode through WithPaginator/WithTx - add Page.Kind()/PageKind and ErrPageKindMismatch --- cursor.go | 5 ++ page.go | 29 +++++++ table.go | 154 ++++++++++++++++++++++++++++++++++- tests/cursor_test.go | 185 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 2 deletions(-) diff --git a/cursor.go b/cursor.go index 749764e..b12f129 100644 --- a/cursor.go +++ b/cursor.go @@ -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. @@ -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 } diff --git a/page.go b/page.go index 367f735..39af090 100644 --- a/page.go +++ b/page.go @@ -26,6 +26,35 @@ 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 diff --git a/table.go b/table.go index f2029a3..08b95a2 100644 --- a/table.go +++ b/table.go @@ -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. @@ -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}} @@ -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 } @@ -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, } } @@ -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, } } diff --git a/tests/cursor_test.go b/tests/cursor_test.go index 14f531c..ecf0b6a 100644 --- a/tests/cursor_test.go +++ b/tests/cursor_test.go @@ -5,6 +5,7 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/goware/pgkit/v2" + "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) @@ -71,6 +72,190 @@ func TestCursorPaginatorPaginateReturnsPage(t *testing.T) { require.NotEqual(t, a.ID, b.ID, "cursor pages should not overlap") } } + + page.Cursor = page.NextCursor + third, thirdPage, err := paginator.Paginate(ctx, db.Query, q, page) + require.NoError(t, err) + require.Len(t, third, 1) + require.False(t, thirdPage.More) + require.Empty(t, thirdPage.NextCursor, "final page must not leak a stale cursor") +} + +func TestTableListPagedCursor(t *testing.T) { + ctx := t.Context() + db := initDB(DB) + + account := &Account{Name: "ListPagedCursor Account"} + require.NoError(t, db.Accounts.Save(ctx, account)) + + for range 5 { + require.NoError(t, db.Articles.Save(ctx, &Article{ + AccountID: account.ID, + Author: "Cursor Author", + })) + } + where := sq.Eq{"account_id": account.ID} + cur := db.Articles.WithMode(pgkit.CursorBased) // mode is the table's property, not the page's + + // walk follows NextCursor from the given first page to the end, collecting ids in server order. + walk := func(t *testing.T, first *pgkit.Page) []uint64 { + t.Helper() + var ids []uint64 + page := first + for { + rows, p, err := cur.ListPaged(ctx, where, page) + require.NoError(t, err) + require.LessOrEqual(t, len(rows), 2) + for _, r := range rows { + ids = append(ids, r.ID) + } + if !p.More { + require.Empty(t, p.NextCursor) + break + } + require.NotEmpty(t, p.NextCursor) + page = &pgkit.Page{Size: 2, Cursor: p.NextCursor} + } + return ids + } + + t.Run("cursor table walks ascending by default", func(t *testing.T) { + ids := walk(t, &pgkit.Page{Size: 2}) + require.Len(t, ids, 5) + for i := 1; i < len(ids); i++ { + require.Less(t, ids[i-1], ids[i], "ids must strictly ascend across pages") + } + }) + + t.Run("cursor table walks descending when the first page asks for it", func(t *testing.T) { + ids := walk(t, &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}}) + require.Len(t, ids, 5) + for i := 1; i < len(ids); i++ { + require.Greater(t, ids[i-1], ids[i], "ids must strictly descend across pages") + } + }) + + t.Run("round-tripping the returned page continues the walk", func(t *testing.T) { + page := &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}} + var ids []uint64 + for { + rows, p, err := cur.ListPaged(ctx, where, page) + require.NoError(t, err) + for _, r := range rows { + ids = append(ids, r.ID) + } + if !p.More { + require.Empty(t, p.NextCursor, "final page must not leak a stale cursor") + break + } + p.Cursor = p.NextCursor + page = p + } + require.Len(t, ids, 5) + }) + + t.Run("offset table never mints a cursor, even ordered by id", func(t *testing.T) { + _, p, err := db.Articles.ListPaged(ctx, where, &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}}) + require.NoError(t, err) + require.True(t, p.More) + require.Empty(t, p.NextCursor) + require.Equal(t, uint32(1), p.Page) + }) + + t.Run("offset table rejects a cursor page", func(t *testing.T) { + _, first, err := cur.ListPaged(ctx, where, &pgkit.Page{Size: 2}) + require.NoError(t, err) + _, _, err = db.Articles.ListPaged(ctx, where, &pgkit.Page{Cursor: first.NextCursor}) + require.ErrorIs(t, err, pgkit.ErrPageKindMismatch) + }) + + t.Run("cursor table rejects a page number", func(t *testing.T) { + _, _, err := cur.ListPaged(ctx, where, &pgkit.Page{Page: 2}) + require.ErrorIs(t, err, pgkit.ErrPageKindMismatch) + }) + + t.Run("a cursor combined with a page number errors", func(t *testing.T) { + _, first, err := cur.ListPaged(ctx, where, &pgkit.Page{Size: 2}) + require.NoError(t, err) + _, _, err = cur.ListPaged(ctx, where, &pgkit.Page{Page: 2, Cursor: first.NextCursor}) + require.ErrorIs(t, err, pgkit.ErrCursorPaged) + }) + + t.Run("cursor table rejects a non-id order", func(t *testing.T) { + _, _, err := cur.ListPaged(ctx, where, &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "author"}}}) + require.ErrorIs(t, err, pgkit.ErrCursorPageOrdered) + }) + + t.Run("continuation with a conflicting page order errors", func(t *testing.T) { + _, first, err := cur.ListPaged(ctx, where, &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}}) + require.NoError(t, err) + _, _, err = cur.ListPaged(ctx, where, &pgkit.Page{Cursor: first.NextCursor, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Asc}}}) + require.ErrorIs(t, err, pgkit.ErrCursorPageOrdered) + }) + + t.Run("WithTx keeps Mode", func(t *testing.T) { + require.NoError(t, pgx.BeginFunc(ctx, DB.Conn, func(pgTx pgx.Tx) error { + _, p, err := cur.WithTx(pgTx).ListPaged(ctx, where, &pgkit.Page{Size: 2}) + require.NoError(t, err) + require.True(t, p.More) + require.NotEmpty(t, p.NextCursor, "keyset mode must survive WithTx") + return nil + })) + }) + + t.Run("first page takes direction from an id default sort", func(t *testing.T) { + ct := db.Articles.WithPaginator(pgkit.WithSort("-id")).WithMode(pgkit.CursorBased) + rows, p, err := ct.ListPaged(ctx, where, &pgkit.Page{Size: 2}) + require.NoError(t, err) + require.NotEmpty(t, p.NextCursor) + for i := 1; i < len(rows); i++ { + require.Greater(t, rows[i-1].ID, rows[i].ID, "default -id sort must walk descending") + } + }) + + t.Run("non-id default sort is ignored for keyset", func(t *testing.T) { + // A default sort like "author" is offset-mode config; keyset must fall + // back to id ASC instead of erroring on config the caller never sent. + ct := db.Articles.WithPaginator(pgkit.WithSort("author")).WithMode(pgkit.CursorBased) + rows, _, err := ct.ListPaged(ctx, where, &pgkit.Page{Size: 2}) + require.NoError(t, err) + for i := 1; i < len(rows); i++ { + require.Less(t, rows[i-1].ID, rows[i].ID, "keyset falls back to id ASC") + } + }) + + t.Run("continuation ignores the table's default sort", func(t *testing.T) { + // Cursor table with an ASC default sort, walked DESC. A bare {Cursor} + // continuation must trust the cursor's direction, not inject the default + // (which would spuriously conflict and return ErrCursorPageOrdered). + ct := db.Articles.WithPaginator(pgkit.WithSort("id")).WithMode(pgkit.CursorBased) + _, first, err := ct.ListPaged(ctx, where, &pgkit.Page{Size: 2, Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}}) + require.NoError(t, err) + require.NotEmpty(t, first.NextCursor) + rows, _, err := ct.ListPaged(ctx, where, &pgkit.Page{Size: 2, Cursor: first.NextCursor}) + require.NoError(t, err) + for i := 1; i < len(rows); i++ { + require.Greater(t, rows[i-1].ID, rows[i].ID, "continuation must keep descending") + } + }) + + t.Run("rejects an undecodable cursor", func(t *testing.T) { + _, _, err := cur.ListPaged(ctx, where, &pgkit.Page{Cursor: "not-a-cursor"}) + require.ErrorIs(t, err, pgkit.ErrInvalidCursor) + }) + + t.Run("rejects a forged cursor order", func(t *testing.T) { + type forgedCursor struct { + ID uint64 `json:"id"` + Order pgkit.Order `json:"order"` + } + for _, order := range []pgkit.Order{"sideways", "asc", ""} { + forged, err := pgkit.EncodeCursor(forgedCursor{ID: 1, Order: order}) + require.NoError(t, err) + _, _, err = cur.ListPaged(ctx, where, &pgkit.Page{Cursor: forged}) + require.ErrorIs(t, err, pgkit.ErrInvalidCursor, "order %q must be rejected", order) + } + }) } func TestPaginatorPaginateReturnsPage(t *testing.T) {