From 40023473ae38b99f2ff45d0d24586592f7be332d 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: Tue, 2 Jun 2026 17:44:04 +0200 Subject: [PATCH 1/8] feat: CursorPaginator for keyset pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of Paginator[T] for ordering-stable pagination under concurrent writes, where offset is too expensive or visibly skips/duplicates rows. - EncodeCursor / DecodeCursor + ErrInvalidCursor (opaque base64+JSON, unsigned — callers re-apply tenant scope per request) - CursorPaginator[T, C] mirrors the offset Paginator[T] lifecycle (PrepareQuery -> GetAll -> PrepareResult) so call sites stay uniform - Page gains Cursor / NextCursor on the shared type so callers can swap paginators without changing the Page type - Caller owns ORDER BY; applyCursor must match it (documented + tested) - nil applyCursor / cursorFromRow panic on entry to fail on the first request rather than the second --- cursor.go | 113 ++++++++++++++++++++ cursor_test.go | 277 +++++++++++++++++++++++++++++++++++++++++++++++++ page.go | 4 + 3 files changed, 394 insertions(+) create mode 100644 cursor.go create mode 100644 cursor_test.go diff --git a/cursor.go b/cursor.go new file mode 100644 index 0000000..e363eb2 --- /dev/null +++ b/cursor.go @@ -0,0 +1,113 @@ +package pgkit + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + sq "github.com/Masterminds/squirrel" +) + +// ErrInvalidCursor signals a client-supplied cursor that failed to decode — map to 400, not 500. +var ErrInvalidCursor = errors.New("invalid cursor") + +// EncodeCursor produces an opaque cursor: base64-JSON, not signed, never use it for authorization. +func EncodeCursor[C any](cursor C) (string, error) { + raw, err := json.Marshal(cursor) + if err != nil { + return "", fmt.Errorf("marshal cursor: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// DecodeCursor returns (nil, nil) for empty input so callers can compose with a nil-check. +func DecodeCursor[C any](value string) (*C, error) { + if value == "" { + return nil, nil + } + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return nil, ErrInvalidCursor + } + var cursor C + if err := json.Unmarshal(raw, &cursor); err != nil { + return nil, ErrInvalidCursor + } + return &cursor, nil +} + +// CursorPaginator is the keyset sibling of Paginator[T] for ordering-stable pagination under concurrent writes. +// The caller owns ORDER BY; applyCursor must match it or pages will silently skip or duplicate rows. +type CursorPaginator[T any, C any] struct { + settings PaginatorSettings +} + +// NewCursorPaginator honors only size options — WithSort / WithColumnFunc are no-ops because the caller owns ORDER BY. +func NewCursorPaginator[T any, C any](options ...PaginatorOption) CursorPaginator[T, C] { + settings := &PaginatorSettings{ + DefaultSize: DefaultPageSize, + MaxSize: MaxPageSize, + } + for _, option := range options { + option(settings) + } + if settings.MaxSize < settings.DefaultSize { + settings.MaxSize = settings.DefaultSize + } + return CursorPaginator[T, C]{settings: *settings} +} + +// PrepareQuery chains LIMIT n+1 so PrepareResult can detect a next page without a second round-trip. +func (p CursorPaginator[T, C]) PrepareQuery( + q sq.SelectBuilder, + page *Page, + applyCursor func(sq.SelectBuilder, C) sq.SelectBuilder, +) ([]T, sq.SelectBuilder, error) { + if applyCursor == nil { + panic("pgkit: CursorPaginator.PrepareQuery: applyCursor must not be nil") + } + if page == nil { + page = &Page{} + } + page.SetDefaults(&p.settings) + + if page.Cursor != "" { + cursor, err := DecodeCursor[C](page.Cursor) + if err != nil { + return nil, q, err + } + q = applyCursor(q, *cursor) + } + + limit := page.Limit() + q = q.Limit(limit + 1) + return make([]T, 0, limit+1), q, nil +} + +// PrepareResult must be called after GetAll to populate page.More and page.NextCursor. +func (p CursorPaginator[T, C]) PrepareResult( + result []T, + page *Page, + cursorFromRow func(T) (C, error), +) ([]T, error) { + if cursorFromRow == nil { + panic("pgkit: CursorPaginator.PrepareResult: cursorFromRow must not be nil") + } + limit := int(page.Limit()) + page.More = len(result) > limit + if page.More { + result = result[:limit] + cursor, err := cursorFromRow(result[len(result)-1]) + if err != nil { + return nil, fmt.Errorf("cursor from row: %w", err) + } + next, err := EncodeCursor(cursor) + if err != nil { + return nil, err + } + page.NextCursor = next + } + page.Size = uint32(limit) + return result, nil +} diff --git a/cursor_test.go b/cursor_test.go new file mode 100644 index 0000000..6a28748 --- /dev/null +++ b/cursor_test.go @@ -0,0 +1,277 @@ +package pgkit_test + +import ( + "errors" + "strconv" + "strings" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/goware/pgkit/v2" + "github.com/stretchr/testify/require" +) + +type rowCursor struct { + ID string `json:"id"` +} + +type row struct { + ID string +} + +func applyIDCursor(q sq.SelectBuilder, c rowCursor) sq.SelectBuilder { + return q.Where(sq.Lt{"id": c.ID}) +} + +func cursorFromRow(r row) (rowCursor, error) { + return rowCursor{ID: r.ID}, nil +} + +func TestEncodeDecodeCursorRoundTrip(t *testing.T) { + encoded, err := pgkit.EncodeCursor(rowCursor{ID: "row_1"}) + require.NoError(t, err) + require.NotEmpty(t, encoded) + + decoded, err := pgkit.DecodeCursor[rowCursor](encoded) + require.NoError(t, err) + require.NotNil(t, decoded) + require.Equal(t, "row_1", decoded.ID) +} + +func TestDecodeCursorEmptyReturnsNil(t *testing.T) { + decoded, err := pgkit.DecodeCursor[rowCursor]("") + require.NoError(t, err) + require.Nil(t, decoded) +} + +func TestDecodeCursorInvalidBase64(t *testing.T) { + _, err := pgkit.DecodeCursor[rowCursor]("!!!not-base64!!!") + require.Error(t, err) + require.True(t, errors.Is(err, pgkit.ErrInvalidCursor)) +} + +func TestDecodeCursorInvalidJSON(t *testing.T) { + // Valid base64, invalid JSON payload. + encoded, err := pgkit.EncodeCursor("not a struct") + require.NoError(t, err) + + _, err = pgkit.DecodeCursor[rowCursor](encoded) + require.Error(t, err) + require.True(t, errors.Is(err, pgkit.ErrInvalidCursor)) +} + +func TestCursorPaginatorFirstPage(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]( + pgkit.WithDefaultSize(2), + pgkit.WithMaxSize(5), + ) + page := &pgkit.Page{} + + result, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + require.Len(t, result, 0) + require.Equal(t, 3, cap(result)) + + sql, args, err := q.ToSql() + require.NoError(t, err) + require.Equal(t, "SELECT * FROM t LIMIT 3", sql) + require.Empty(t, args) +} + +func TestCursorPaginatorWithCursor(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + encoded, err := pgkit.EncodeCursor(rowCursor{ID: "row_5"}) + require.NoError(t, err) + page := &pgkit.Page{Cursor: encoded} + + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + sql, args, err := q.ToSql() + require.NoError(t, err) + require.Equal(t, "SELECT * FROM t WHERE id < ? LIMIT 3", sql) + require.Equal(t, []any{"row_5"}, args) +} + +func TestCursorPaginatorInvalidCursor(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]() + page := &pgkit.Page{Cursor: "!!!not-base64!!!"} + + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.Error(t, err) + require.True(t, errors.Is(err, pgkit.ErrInvalidCursor)) +} + +func TestCursorPaginatorPrepareResultNoMore(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(3)) + page := &pgkit.Page{} + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + result, err := paginator.PrepareResult([]row{{ID: "1"}, {ID: "2"}}, page, cursorFromRow) + require.NoError(t, err) + require.Len(t, result, 2) + require.False(t, page.More) + require.Empty(t, page.NextCursor) + require.Equal(t, uint32(3), page.Size) +} + +func TestCursorPaginatorPrepareResultHasMore(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + page := &pgkit.Page{} + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + // Three rows returned, limit was 2 — the third signals "more". + result, err := paginator.PrepareResult( + []row{{ID: "3"}, {ID: "2"}, {ID: "1"}}, + page, + cursorFromRow, + ) + require.NoError(t, err) + require.Equal(t, []row{{ID: "3"}, {ID: "2"}}, result) + require.True(t, page.More) + require.NotEmpty(t, page.NextCursor) + + // NextCursor must round-trip back to the last surviving row. + decoded, err := pgkit.DecodeCursor[rowCursor](page.NextCursor) + require.NoError(t, err) + require.NotNil(t, decoded) + require.Equal(t, "2", decoded.ID) +} + +func TestCursorPaginatorDefaultsFromNilPage(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]() + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), nil, applyIDCursor) + require.NoError(t, err) + + sql, _, err := q.ToSql() + require.NoError(t, err) + // Default page size is 10 → LIMIT 11. + require.Equal(t, "SELECT * FROM t LIMIT 11", sql) +} + +func TestCursorPaginatorCapsAtMaxSize(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]( + pgkit.WithDefaultSize(5), + pgkit.WithMaxSize(10), + ) + page := &pgkit.Page{Size: 999} + + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + sql, _, err := q.ToSql() + require.NoError(t, err) + require.Equal(t, "SELECT * FROM t LIMIT 11", sql) + require.Equal(t, uint32(10), page.Size) +} + +func TestCursorPaginatorMaxSizeBelowDefaultIsLifted(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]( + pgkit.WithDefaultSize(20), + pgkit.WithMaxSize(5), + ) + page := &pgkit.Page{} + + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + sql, _, err := q.ToSql() + require.NoError(t, err) + // MaxSize is lifted to DefaultSize, so DefaultSize wins → LIMIT 21. + require.Equal(t, "SELECT * FROM t LIMIT 21", sql) +} + +func TestCursorPaginatorWalksPages(t *testing.T) { + // End-to-end: paginate a fixed 5-row dataset in pages of 2 and + // verify every row surfaces exactly once across three pages. + paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + all := []row{{ID: "5"}, {ID: "4"}, {ID: "3"}, {ID: "2"}, {ID: "1"}} + + var ( + page = &pgkit.Page{} + seen []row + ) + for step := 0; step < 5; step++ { + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + fetched := fetch(t, all, q) + got, err := paginator.PrepareResult(fetched, page, cursorFromRow) + require.NoError(t, err) + + seen = append(seen, got...) + if !page.More { + break + } + page.Cursor = page.NextCursor + page.NextCursor = "" + } + require.Equal(t, all, seen) + require.False(t, page.More) +} + +func TestCursorPaginatorPrepareResultPropagatesCursorError(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(1)) + page := &pgkit.Page{} + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + + sentinel := errors.New("boom") + _, err = paginator.PrepareResult( + []row{{ID: "2"}, {ID: "1"}}, + page, + func(row) (rowCursor, error) { return rowCursor{}, sentinel }, + ) + require.Error(t, err) + require.True(t, errors.Is(err, sentinel)) +} + +func TestCursorPaginatorPanicsOnNilApplyCursor(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]() + require.PanicsWithValue( + t, + "pgkit: CursorPaginator.PrepareQuery: applyCursor must not be nil", + func() { _, _, _ = paginator.PrepareQuery(sq.Select("*").From("t"), &pgkit.Page{}, nil) }, + ) +} + +func TestCursorPaginatorPanicsOnNilCursorFromRow(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor]() + page := &pgkit.Page{} + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + require.NoError(t, err) + require.PanicsWithValue( + t, + "pgkit: CursorPaginator.PrepareResult: cursorFromRow must not be nil", + func() { _, _ = paginator.PrepareResult([]row{{ID: "1"}}, page, nil) }, + ) +} + +// In-memory stand-in so the pagination walk exercises encode/decode without a real database. +func fetch(t *testing.T, all []row, q sq.SelectBuilder) []row { + t.Helper() + sql, args, err := q.ToSql() + require.NoError(t, err) + + limit, err := strconv.Atoi(sql[strings.LastIndex(sql, " ")+1:]) + require.NoError(t, err) + + cutoff := "" + if len(args) == 1 { + cutoff = args[0].(string) + } + + out := make([]row, 0, limit) + for _, r := range all { + if cutoff != "" && r.ID >= cutoff { + continue + } + out = append(out, r) + if len(out) == limit { + break + } + } + return out +} diff --git a/page.go b/page.go index 35621e7..6f2de60 100644 --- a/page.go +++ b/page.go @@ -81,6 +81,10 @@ type Page struct { More bool Column string Sort []Sort + + // Unused by the offset Paginator — shared here so callers can swap paginators without changing the Page type. + Cursor string + NextCursor string } func NewPage(size, page uint32, sort ...Sort) *Page { From 32406825649f8ffa3745572139de09ac6ecf87a1 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: Tue, 2 Jun 2026 17:58:46 +0200 Subject: [PATCH 2/8] refactor: bind cursor behavior to the cursor type via interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two callback positional args on PrepareQuery / PrepareResult with a Cursor[Self, Row] interface constraint, mirroring the self-pointer convention pgkit already uses on Table[T, P, I] -> Record[T, I]. - Cursor[Self, Row] interface: *Self + Apply + From, hangs methods on the pointer so From can populate the value and Apply can read it - CursorPaginator gains a third type parameter PC Cursor[C, T]; callers spell it like NewCursorPaginator[*Row, Cursor, *Cursor](...) - Drops the nil-check + panic on applyCursor / cursorFromRow — missing methods are now a compile error - Cursor behavior co-located on the cursor type (Apply, From) instead of split between the type + a free function at the call site --- cursor.go | 39 ++++++++---------- cursor_test.go | 107 ++++++++++++++++++++----------------------------- 2 files changed, 60 insertions(+), 86 deletions(-) diff --git a/cursor.go b/cursor.go index e363eb2..52b7186 100644 --- a/cursor.go +++ b/cursor.go @@ -37,14 +37,21 @@ func DecodeCursor[C any](value string) (*C, error) { return &cursor, nil } +// Cursor is the interface a typed keyset cursor satisfies — mirrors pgkit.Record[T, I]'s self-pointer pattern. +type Cursor[Self any, Row any] interface { + *Self + Apply(sq.SelectBuilder) sq.SelectBuilder + From(Row) error +} + // CursorPaginator is the keyset sibling of Paginator[T] for ordering-stable pagination under concurrent writes. -// The caller owns ORDER BY; applyCursor must match it or pages will silently skip or duplicate rows. -type CursorPaginator[T any, C any] struct { +// The caller owns ORDER BY; C.Apply must match it or pages will silently skip or duplicate rows. +type CursorPaginator[T any, C any, PC Cursor[C, T]] struct { settings PaginatorSettings } // NewCursorPaginator honors only size options — WithSort / WithColumnFunc are no-ops because the caller owns ORDER BY. -func NewCursorPaginator[T any, C any](options ...PaginatorOption) CursorPaginator[T, C] { +func NewCursorPaginator[T any, C any, PC Cursor[C, T]](options ...PaginatorOption) CursorPaginator[T, C, PC] { settings := &PaginatorSettings{ DefaultSize: DefaultPageSize, MaxSize: MaxPageSize, @@ -55,18 +62,11 @@ func NewCursorPaginator[T any, C any](options ...PaginatorOption) CursorPaginato if settings.MaxSize < settings.DefaultSize { settings.MaxSize = settings.DefaultSize } - return CursorPaginator[T, C]{settings: *settings} + return CursorPaginator[T, C, PC]{settings: *settings} } // PrepareQuery chains LIMIT n+1 so PrepareResult can detect a next page without a second round-trip. -func (p CursorPaginator[T, C]) PrepareQuery( - q sq.SelectBuilder, - page *Page, - applyCursor func(sq.SelectBuilder, C) sq.SelectBuilder, -) ([]T, sq.SelectBuilder, error) { - if applyCursor == nil { - panic("pgkit: CursorPaginator.PrepareQuery: applyCursor must not be nil") - } +func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) ([]T, sq.SelectBuilder, error) { if page == nil { page = &Page{} } @@ -77,7 +77,7 @@ func (p CursorPaginator[T, C]) PrepareQuery( if err != nil { return nil, q, err } - q = applyCursor(q, *cursor) + q = PC(cursor).Apply(q) } limit := page.Limit() @@ -86,20 +86,13 @@ func (p CursorPaginator[T, C]) PrepareQuery( } // PrepareResult must be called after GetAll to populate page.More and page.NextCursor. -func (p CursorPaginator[T, C]) PrepareResult( - result []T, - page *Page, - cursorFromRow func(T) (C, error), -) ([]T, error) { - if cursorFromRow == nil { - panic("pgkit: CursorPaginator.PrepareResult: cursorFromRow must not be nil") - } +func (p CursorPaginator[T, C, PC]) PrepareResult(result []T, page *Page) ([]T, error) { limit := int(page.Limit()) page.More = len(result) > limit if page.More { result = result[:limit] - cursor, err := cursorFromRow(result[len(result)-1]) - if err != nil { + var cursor C + if err := PC(&cursor).From(result[len(result)-1]); err != nil { return nil, fmt.Errorf("cursor from row: %w", err) } next, err := EncodeCursor(cursor) diff --git a/cursor_test.go b/cursor_test.go index 6a28748..0ff530a 100644 --- a/cursor_test.go +++ b/cursor_test.go @@ -11,20 +11,21 @@ import ( "github.com/stretchr/testify/require" ) -type rowCursor struct { - ID string `json:"id"` -} - type row struct { ID string } -func applyIDCursor(q sq.SelectBuilder, c rowCursor) sq.SelectBuilder { +type rowCursor struct { + ID string `json:"id"` +} + +func (c *rowCursor) Apply(q sq.SelectBuilder) sq.SelectBuilder { return q.Where(sq.Lt{"id": c.ID}) } -func cursorFromRow(r row) (rowCursor, error) { - return rowCursor{ID: r.ID}, nil +func (c *rowCursor) From(r row) error { + c.ID = r.ID + return nil } func TestEncodeDecodeCursorRoundTrip(t *testing.T) { @@ -51,7 +52,6 @@ func TestDecodeCursorInvalidBase64(t *testing.T) { } func TestDecodeCursorInvalidJSON(t *testing.T) { - // Valid base64, invalid JSON payload. encoded, err := pgkit.EncodeCursor("not a struct") require.NoError(t, err) @@ -61,13 +61,13 @@ func TestDecodeCursorInvalidJSON(t *testing.T) { } func TestCursorPaginatorFirstPage(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]( + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]( pgkit.WithDefaultSize(2), pgkit.WithMaxSize(5), ) page := &pgkit.Page{} - result, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + result, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) require.Len(t, result, 0) require.Equal(t, 3, cap(result)) @@ -79,12 +79,12 @@ func TestCursorPaginatorFirstPage(t *testing.T) { } func TestCursorPaginatorWithCursor(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor](pgkit.WithDefaultSize(2)) encoded, err := pgkit.EncodeCursor(rowCursor{ID: "row_5"}) require.NoError(t, err) page := &pgkit.Page{Cursor: encoded} - _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) sql, args, err := q.ToSql() @@ -94,21 +94,21 @@ func TestCursorPaginatorWithCursor(t *testing.T) { } func TestCursorPaginatorInvalidCursor(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]() + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() page := &pgkit.Page{Cursor: "!!!not-base64!!!"} - _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.Error(t, err) require.True(t, errors.Is(err, pgkit.ErrInvalidCursor)) } func TestCursorPaginatorPrepareResultNoMore(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(3)) + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor](pgkit.WithDefaultSize(3)) page := &pgkit.Page{} - _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) - result, err := paginator.PrepareResult([]row{{ID: "1"}, {ID: "2"}}, page, cursorFromRow) + result, err := paginator.PrepareResult([]row{{ID: "1"}, {ID: "2"}}, page) require.NoError(t, err) require.Len(t, result, 2) require.False(t, page.More) @@ -117,23 +117,20 @@ func TestCursorPaginatorPrepareResultNoMore(t *testing.T) { } func TestCursorPaginatorPrepareResultHasMore(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor](pgkit.WithDefaultSize(2)) page := &pgkit.Page{} - _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) - // Three rows returned, limit was 2 — the third signals "more". result, err := paginator.PrepareResult( []row{{ID: "3"}, {ID: "2"}, {ID: "1"}}, page, - cursorFromRow, ) require.NoError(t, err) require.Equal(t, []row{{ID: "3"}, {ID: "2"}}, result) require.True(t, page.More) require.NotEmpty(t, page.NextCursor) - // NextCursor must round-trip back to the last surviving row. decoded, err := pgkit.DecodeCursor[rowCursor](page.NextCursor) require.NoError(t, err) require.NotNil(t, decoded) @@ -141,24 +138,23 @@ func TestCursorPaginatorPrepareResultHasMore(t *testing.T) { } func TestCursorPaginatorDefaultsFromNilPage(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]() - _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), nil, applyIDCursor) + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), nil) require.NoError(t, err) sql, _, err := q.ToSql() require.NoError(t, err) - // Default page size is 10 → LIMIT 11. require.Equal(t, "SELECT * FROM t LIMIT 11", sql) } func TestCursorPaginatorCapsAtMaxSize(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]( + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]( pgkit.WithDefaultSize(5), pgkit.WithMaxSize(10), ) page := &pgkit.Page{Size: 999} - _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) sql, _, err := q.ToSql() @@ -168,25 +164,22 @@ func TestCursorPaginatorCapsAtMaxSize(t *testing.T) { } func TestCursorPaginatorMaxSizeBelowDefaultIsLifted(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]( + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]( pgkit.WithDefaultSize(20), pgkit.WithMaxSize(5), ) page := &pgkit.Page{} - _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) sql, _, err := q.ToSql() require.NoError(t, err) - // MaxSize is lifted to DefaultSize, so DefaultSize wins → LIMIT 21. require.Equal(t, "SELECT * FROM t LIMIT 21", sql) } func TestCursorPaginatorWalksPages(t *testing.T) { - // End-to-end: paginate a fixed 5-row dataset in pages of 2 and - // verify every row surfaces exactly once across three pages. - paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(2)) + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor](pgkit.WithDefaultSize(2)) all := []row{{ID: "5"}, {ID: "4"}, {ID: "3"}, {ID: "2"}, {ID: "1"}} var ( @@ -194,11 +187,11 @@ func TestCursorPaginatorWalksPages(t *testing.T) { seen []row ) for step := 0; step < 5; step++ { - _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) fetched := fetch(t, all, q) - got, err := paginator.PrepareResult(fetched, page, cursorFromRow) + got, err := paginator.PrepareResult(fetched, page) require.NoError(t, err) seen = append(seen, got...) @@ -212,41 +205,29 @@ func TestCursorPaginatorWalksPages(t *testing.T) { require.False(t, page.More) } -func TestCursorPaginatorPrepareResultPropagatesCursorError(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor](pgkit.WithDefaultSize(1)) - page := &pgkit.Page{} - _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) - require.NoError(t, err) +type failingRowCursor struct { + ID string `json:"id"` +} - sentinel := errors.New("boom") - _, err = paginator.PrepareResult( - []row{{ID: "2"}, {ID: "1"}}, - page, - func(row) (rowCursor, error) { return rowCursor{}, sentinel }, - ) - require.Error(t, err) - require.True(t, errors.Is(err, sentinel)) +func (c *failingRowCursor) Apply(q sq.SelectBuilder) sq.SelectBuilder { + return q.Where(sq.Lt{"id": c.ID}) } -func TestCursorPaginatorPanicsOnNilApplyCursor(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]() - require.PanicsWithValue( - t, - "pgkit: CursorPaginator.PrepareQuery: applyCursor must not be nil", - func() { _, _, _ = paginator.PrepareQuery(sq.Select("*").From("t"), &pgkit.Page{}, nil) }, - ) +var errBoom = errors.New("boom") + +func (c *failingRowCursor) From(row) error { + return errBoom } -func TestCursorPaginatorPanicsOnNilCursorFromRow(t *testing.T) { - paginator := pgkit.NewCursorPaginator[row, rowCursor]() +func TestCursorPaginatorPrepareResultPropagatesCursorError(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, failingRowCursor, *failingRowCursor](pgkit.WithDefaultSize(1)) page := &pgkit.Page{} - _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page, applyIDCursor) + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), page) require.NoError(t, err) - require.PanicsWithValue( - t, - "pgkit: CursorPaginator.PrepareResult: cursorFromRow must not be nil", - func() { _, _ = paginator.PrepareResult([]row{{ID: "1"}}, page, nil) }, - ) + + _, err = paginator.PrepareResult([]row{{ID: "2"}, {ID: "1"}}, page) + require.Error(t, err) + require.True(t, errors.Is(err, errBoom)) } // In-memory stand-in so the pagination walk exercises encode/decode without a real database. From 7f30c0a62ad85346b423173241e3cd912a8fe361 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: Tue, 2 Jun 2026 18:00:18 +0200 Subject: [PATCH 3/8] refactor(cursor): early-return PrepareResult on the no-more branch --- cursor.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/cursor.go b/cursor.go index 52b7186..49f4814 100644 --- a/cursor.go +++ b/cursor.go @@ -88,19 +88,21 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) // PrepareResult must be called after GetAll to populate page.More and page.NextCursor. func (p CursorPaginator[T, C, PC]) PrepareResult(result []T, page *Page) ([]T, error) { limit := int(page.Limit()) + page.Size = uint32(limit) page.More = len(result) > limit - if page.More { - result = result[:limit] - var cursor C - if err := PC(&cursor).From(result[len(result)-1]); err != nil { - return nil, fmt.Errorf("cursor from row: %w", err) - } - next, err := EncodeCursor(cursor) - if err != nil { - return nil, err - } - page.NextCursor = next + if !page.More { + return result, nil } - page.Size = uint32(limit) + result = result[:limit] + + var cursor C + if err := PC(&cursor).From(result[len(result)-1]); err != nil { + return nil, fmt.Errorf("cursor from row: %w", err) + } + next, err := EncodeCursor(cursor) + if err != nil { + return nil, err + } + page.NextCursor = next return result, nil } From afa154f1379f0489599ea12385fca758619f38b0 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: Thu, 11 Jun 2026 10:00:00 +0200 Subject: [PATCH 4/8] fix(cursor): bind order to cursor contract - add Cursor.OrderBy and apply it in PrepareQuery - reject preordered Squirrel builders before adding cursor order - reuse PtrTo for Record and Cursor pointer constraints --- cursor.go | 22 +++++++++++++++++----- cursor_test.go | 25 ++++++++++++++++++++----- go.mod | 2 +- table.go | 7 ++++++- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cursor.go b/cursor.go index 49f4814..7621ba6 100644 --- a/cursor.go +++ b/cursor.go @@ -7,10 +7,15 @@ import ( "fmt" sq "github.com/Masterminds/squirrel" + "github.com/lann/builder" ) -// ErrInvalidCursor signals a client-supplied cursor that failed to decode — map to 400, not 500. -var ErrInvalidCursor = errors.New("invalid cursor") +var ( + // ErrInvalidCursor signals a client-supplied cursor that failed to decode - map to 400, not 500. + ErrInvalidCursor = errors.New("invalid cursor") + // ErrCursorQueryOrdered signals a cursor-paginated query that already had ORDER BY. + ErrCursorQueryOrdered = errors.New("cursor query already has order by") +) // EncodeCursor produces an opaque cursor: base64-JSON, not signed, never use it for authorization. func EncodeCursor[C any](cursor C) (string, error) { @@ -39,18 +44,18 @@ func DecodeCursor[C any](value string) (*C, error) { // Cursor is the interface a typed keyset cursor satisfies — mirrors pgkit.Record[T, I]'s self-pointer pattern. type Cursor[Self any, Row any] interface { - *Self + PtrTo[Self] Apply(sq.SelectBuilder) sq.SelectBuilder From(Row) error + OrderBy() []Sort } // CursorPaginator is the keyset sibling of Paginator[T] for ordering-stable pagination under concurrent writes. -// The caller owns ORDER BY; C.Apply must match it or pages will silently skip or duplicate rows. type CursorPaginator[T any, C any, PC Cursor[C, T]] struct { settings PaginatorSettings } -// NewCursorPaginator honors only size options — WithSort / WithColumnFunc are no-ops because the caller owns ORDER BY. +// NewCursorPaginator honors only size options - the cursor owns ORDER BY. func NewCursorPaginator[T any, C any, PC Cursor[C, T]](options ...PaginatorOption) CursorPaginator[T, C, PC] { settings := &PaginatorSettings{ DefaultSize: DefaultPageSize, @@ -72,6 +77,13 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) } page.SetDefaults(&p.settings) + if _, ok := builder.Get(q, "OrderByParts"); ok { + return nil, q, ErrCursorQueryOrdered + } + var zero C + for _, sort := range PC(&zero).OrderBy() { + q = q.OrderBy(sort.String()) + } if page.Cursor != "" { cursor, err := DecodeCursor[C](page.Cursor) if err != nil { diff --git a/cursor_test.go b/cursor_test.go index 0ff530a..223b931 100644 --- a/cursor_test.go +++ b/cursor_test.go @@ -23,6 +23,10 @@ func (c *rowCursor) Apply(q sq.SelectBuilder) sq.SelectBuilder { return q.Where(sq.Lt{"id": c.ID}) } +func (c *rowCursor) OrderBy() []pgkit.Sort { + return []pgkit.Sort{{Column: "id", Order: pgkit.Desc}} +} + func (c *rowCursor) From(r row) error { c.ID = r.ID return nil @@ -74,7 +78,7 @@ func TestCursorPaginatorFirstPage(t *testing.T) { sql, args, err := q.ToSql() require.NoError(t, err) - require.Equal(t, "SELECT * FROM t LIMIT 3", sql) + require.Equal(t, `SELECT * FROM t ORDER BY "id" DESC LIMIT 3`, sql) require.Empty(t, args) } @@ -89,10 +93,17 @@ func TestCursorPaginatorWithCursor(t *testing.T) { sql, args, err := q.ToSql() require.NoError(t, err) - require.Equal(t, "SELECT * FROM t WHERE id < ? LIMIT 3", sql) + require.Equal(t, `SELECT * FROM t WHERE id < ? ORDER BY "id" DESC LIMIT 3`, sql) require.Equal(t, []any{"row_5"}, args) } +func TestCursorPaginatorRejectsPreorderedQuery(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() + + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t").OrderBy("name"), &pgkit.Page{}) + require.ErrorIs(t, err, pgkit.ErrCursorQueryOrdered) +} + func TestCursorPaginatorInvalidCursor(t *testing.T) { paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() page := &pgkit.Page{Cursor: "!!!not-base64!!!"} @@ -144,7 +155,7 @@ func TestCursorPaginatorDefaultsFromNilPage(t *testing.T) { sql, _, err := q.ToSql() require.NoError(t, err) - require.Equal(t, "SELECT * FROM t LIMIT 11", sql) + require.Equal(t, `SELECT * FROM t ORDER BY "id" DESC LIMIT 11`, sql) } func TestCursorPaginatorCapsAtMaxSize(t *testing.T) { @@ -159,7 +170,7 @@ func TestCursorPaginatorCapsAtMaxSize(t *testing.T) { sql, _, err := q.ToSql() require.NoError(t, err) - require.Equal(t, "SELECT * FROM t LIMIT 11", sql) + require.Equal(t, `SELECT * FROM t ORDER BY "id" DESC LIMIT 11`, sql) require.Equal(t, uint32(10), page.Size) } @@ -175,7 +186,7 @@ func TestCursorPaginatorMaxSizeBelowDefaultIsLifted(t *testing.T) { sql, _, err := q.ToSql() require.NoError(t, err) - require.Equal(t, "SELECT * FROM t LIMIT 21", sql) + require.Equal(t, `SELECT * FROM t ORDER BY "id" DESC LIMIT 21`, sql) } func TestCursorPaginatorWalksPages(t *testing.T) { @@ -213,6 +224,10 @@ func (c *failingRowCursor) Apply(q sq.SelectBuilder) sq.SelectBuilder { return q.Where(sq.Lt{"id": c.ID}) } +func (c *failingRowCursor) OrderBy() []pgkit.Sort { + return []pgkit.Sort{{Column: "id", Order: pgkit.Desc}} +} + var errBoom = errors.New("boom") func (c *failingRowCursor) From(row) error { diff --git a/go.mod b/go.mod index 7d1e982..9f9ce1c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/georgysavva/scany/v2 v2.1.4 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.9.0 + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 github.com/stretchr/testify v1.11.1 ) @@ -16,7 +17,6 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect - github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/table.go b/table.go index b185f46..f2029a3 100644 --- a/table.go +++ b/table.go @@ -15,9 +15,14 @@ import ( // ID is a comparable type used for record IDs. type ID comparable +// PtrTo constrains a type parameter to be a pointer to T. +type PtrTo[T any] interface { + *T +} + // Records must be a pointer with the methods defined on the pointer. type Record[T any, I ID] interface { - *T // Enforce T is a pointer. + PtrTo[T] GetID() I Validate() error } From f6ebd4aa10211ba6ad15c4c139dc4649c1dd2552 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: Thu, 11 Jun 2026 10:02:11 +0200 Subject: [PATCH 5/8] fix(cursor): reject page-level ordering - return ErrCursorPageOrdered for Page.Sort or Page.Column - cover both page-level order inputs in cursor paginator tests --- cursor.go | 5 +++++ cursor_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/cursor.go b/cursor.go index 7621ba6..504a978 100644 --- a/cursor.go +++ b/cursor.go @@ -15,6 +15,8 @@ var ( ErrInvalidCursor = errors.New("invalid cursor") // ErrCursorQueryOrdered signals a cursor-paginated query that already had ORDER BY. ErrCursorQueryOrdered = errors.New("cursor query already has order by") + // ErrCursorPageOrdered signals cursor pagination with page-level ordering. + ErrCursorPageOrdered = errors.New("cursor page already has order") ) // EncodeCursor produces an opaque cursor: base64-JSON, not signed, never use it for authorization. @@ -77,6 +79,9 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) } page.SetDefaults(&p.settings) + if page.Column != "" || len(page.Sort) != 0 { + return nil, q, ErrCursorPageOrdered + } if _, ok := builder.Get(q, "OrderByParts"); ok { return nil, q, ErrCursorQueryOrdered } diff --git a/cursor_test.go b/cursor_test.go index 223b931..851c043 100644 --- a/cursor_test.go +++ b/cursor_test.go @@ -104,6 +104,31 @@ func TestCursorPaginatorRejectsPreorderedQuery(t *testing.T) { require.ErrorIs(t, err, pgkit.ErrCursorQueryOrdered) } +func TestCursorPaginatorRejectsPageOrder(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() + + tests := []struct { + name string + page *pgkit.Page + }{ + { + name: "sort", + page: &pgkit.Page{Sort: []pgkit.Sort{{Column: "name", Order: pgkit.Asc}}}, + }, + { + name: "column", + page: &pgkit.Page{Column: "name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := paginator.PrepareQuery(sq.Select("*").From("t"), tt.page) + require.ErrorIs(t, err, pgkit.ErrCursorPageOrdered) + }) + } +} + func TestCursorPaginatorInvalidCursor(t *testing.T) { paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() page := &pgkit.Page{Cursor: "!!!not-base64!!!"} From 59eff687119d460019c9f61f2f6570d02924690f 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: Thu, 11 Jun 2026 11:05:01 +0200 Subject: [PATCH 6/8] fix(cursor): allow matching page order - allow Page.Sort or Page.Column when normalized order matches Cursor.OrderBy - keep rejecting mismatched page order - document that cursor order must match Apply and include a tiebreaker --- cursor.go | 20 ++++++++++++++------ cursor_test.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/cursor.go b/cursor.go index 504a978..a5b6b63 100644 --- a/cursor.go +++ b/cursor.go @@ -15,8 +15,8 @@ var ( ErrInvalidCursor = errors.New("invalid cursor") // ErrCursorQueryOrdered signals a cursor-paginated query that already had ORDER BY. ErrCursorQueryOrdered = errors.New("cursor query already has order by") - // ErrCursorPageOrdered signals cursor pagination with page-level ordering. - ErrCursorPageOrdered = errors.New("cursor page already has order") + // ErrCursorPageOrdered signals page-level ordering that does not match the cursor order. + ErrCursorPageOrdered = errors.New("cursor page order does not match cursor order") ) // EncodeCursor produces an opaque cursor: base64-JSON, not signed, never use it for authorization. @@ -49,6 +49,7 @@ type Cursor[Self any, Row any] interface { PtrTo[Self] Apply(sq.SelectBuilder) sq.SelectBuilder From(Row) error + // OrderBy must match Apply and should include a unique tiebreaker. OrderBy() []Sort } @@ -79,14 +80,21 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) } page.SetDefaults(&p.settings) - if page.Column != "" || len(page.Sort) != 0 { - return nil, q, ErrCursorPageOrdered - } if _, ok := builder.Get(q, "OrderByParts"); ok { return nil, q, ErrCursorQueryOrdered } var zero C - for _, sort := range PC(&zero).OrderBy() { + order := PC(&zero).OrderBy() + pageOrder := page.GetOrder(nil) + if len(pageOrder) != 0 && len(pageOrder) != len(order) { + return nil, q, ErrCursorPageOrdered + } + for i := range pageOrder { + if pageOrder[i] != order[i].sanitize(nil) { + return nil, q, ErrCursorPageOrdered + } + } + for _, sort := range order { q = q.OrderBy(sort.String()) } if page.Cursor != "" { diff --git a/cursor_test.go b/cursor_test.go index 851c043..a27571b 100644 --- a/cursor_test.go +++ b/cursor_test.go @@ -104,7 +104,37 @@ func TestCursorPaginatorRejectsPreorderedQuery(t *testing.T) { require.ErrorIs(t, err, pgkit.ErrCursorQueryOrdered) } -func TestCursorPaginatorRejectsPageOrder(t *testing.T) { +func TestCursorPaginatorAllowsMatchingPageOrder(t *testing.T) { + paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() + + tests := []struct { + name string + page *pgkit.Page + }{ + { + name: "sort", + page: &pgkit.Page{Sort: []pgkit.Sort{{Column: "id", Order: pgkit.Desc}}}, + }, + { + name: "column", + page: &pgkit.Page{Column: "-id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, q, err := paginator.PrepareQuery(sq.Select("*").From("t"), tt.page) + require.NoError(t, err) + + sql, args, err := q.ToSql() + require.NoError(t, err) + require.Equal(t, `SELECT * FROM t ORDER BY "id" DESC LIMIT 11`, sql) + require.Empty(t, args) + }) + } +} + +func TestCursorPaginatorRejectsMismatchedPageOrder(t *testing.T) { paginator := pgkit.NewCursorPaginator[row, rowCursor, *rowCursor]() tests := []struct { From 412d4ee9c7e916a4e00075871f83354fa6116246 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: Thu, 11 Jun 2026 11:41:29 +0200 Subject: [PATCH 7/8] fix(cursor): add list helper returning page - add CursorPaginator.List to execute a prepared select and return the populated page - cover nil first page, next cursor, and non-overlapping cursor pages --- cursor.go | 20 ++++++++++++ tests/cursor_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 tests/cursor_test.go diff --git a/cursor.go b/cursor.go index a5b6b63..67785b1 100644 --- a/cursor.go +++ b/cursor.go @@ -1,6 +1,7 @@ package pgkit import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -110,6 +111,25 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) return make([]T, 0, limit+1), q, nil } +// List returns cursor-paginated rows and the page populated with More and NextCursor. +func (p CursorPaginator[T, C, PC]) List(ctx context.Context, query *Querier, q sq.SelectBuilder, page *Page) ([]T, *Page, error) { + if page == nil { + page = &Page{} + } + result, q, err := p.PrepareQuery(q, page) + if err != nil { + return nil, nil, err + } + if err := query.GetAll(ctx, q, &result); err != nil { + return nil, nil, err + } + result, err = p.PrepareResult(result, page) + if err != nil { + return nil, nil, err + } + return result, page, nil +} + // PrepareResult must be called after GetAll to populate page.More and page.NextCursor. func (p CursorPaginator[T, C, PC]) PrepareResult(result []T, page *Page) ([]T, error) { limit := int(page.Limit()) diff --git a/tests/cursor_test.go b/tests/cursor_test.go new file mode 100644 index 0000000..3a00711 --- /dev/null +++ b/tests/cursor_test.go @@ -0,0 +1,74 @@ +package pgkit_test + +import ( + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/goware/pgkit/v2" + "github.com/stretchr/testify/require" +) + +type articleCursor struct { + ID uint64 `json:"id"` +} + +func (c *articleCursor) Apply(q sq.SelectBuilder) sq.SelectBuilder { + return q.Where(sq.Lt{"id": c.ID}) +} + +func (c *articleCursor) OrderBy() []pgkit.Sort { + return []pgkit.Sort{{Column: "id", Order: pgkit.Desc}} +} + +func (c *articleCursor) From(article *Article) error { + c.ID = article.ID + return nil +} + +func TestCursorPaginatorListReturnsPage(t *testing.T) { + ctx := t.Context() + db := initDB(DB) + + account := &Account{Name: "CursorPaginatorList Account"} + err := db.Accounts.Save(ctx, account) + require.NoError(t, err) + + for range 5 { + err := db.Articles.Save(ctx, &Article{ + AccountID: account.ID, + Author: "Cursor Author", + }) + require.NoError(t, err) + } + + paginator := pgkit.NewCursorPaginator[*Article, articleCursor, *articleCursor]( + pgkit.WithDefaultSize(2), + ) + q := db.SQL.Select("*"). + From("articles"). + Where(sq.Eq{"account_id": account.ID}) + + first, firstPage, err := paginator.List(ctx, db.Query, q, nil) + require.NoError(t, err) + require.Len(t, first, 2) + require.NotNil(t, firstPage) + require.Equal(t, uint32(2), firstPage.Size) + require.True(t, firstPage.More) + require.NotEmpty(t, firstPage.NextCursor) + + page := &pgkit.Page{ + Cursor: firstPage.NextCursor, + } + second, secondPage, err := paginator.List(ctx, db.Query, q, page) + require.NoError(t, err) + require.Len(t, second, 2) + require.Same(t, page, secondPage) + require.True(t, secondPage.More) + require.NotEmpty(t, secondPage.NextCursor) + + for _, a := range first { + for _, b := range second { + require.NotEqual(t, a.ID, b.ID, "cursor pages should not overlap") + } + } +} From 77a9953b7a1bfb93170851278891de42b3bba325 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: Thu, 11 Jun 2026 12:43:30 +0200 Subject: [PATCH 8/8] fix(cursor): rename list helper to paginate - rename CursorPaginator.List to Paginate - add matching Paginator.Paginate helper for offset pagination - cover both helpers returning populated page state --- cursor.go | 4 ++-- page.go | 13 +++++++++++ tests/cursor_test.go | 53 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/cursor.go b/cursor.go index 67785b1..749764e 100644 --- a/cursor.go +++ b/cursor.go @@ -111,8 +111,8 @@ func (p CursorPaginator[T, C, PC]) PrepareQuery(q sq.SelectBuilder, page *Page) return make([]T, 0, limit+1), q, nil } -// List returns cursor-paginated rows and the page populated with More and NextCursor. -func (p CursorPaginator[T, C, PC]) List(ctx context.Context, query *Querier, q sq.SelectBuilder, page *Page) ([]T, *Page, error) { +// Paginate returns cursor-paginated rows and the page populated with More and NextCursor. +func (p CursorPaginator[T, C, PC]) Paginate(ctx context.Context, query *Querier, q sq.SelectBuilder, page *Page) ([]T, *Page, error) { if page == nil { page = &Page{} } diff --git a/page.go b/page.go index 6f2de60..3b99f55 100644 --- a/page.go +++ b/page.go @@ -1,6 +1,7 @@ package pgkit import ( + "context" "fmt" "regexp" "slices" @@ -255,6 +256,18 @@ func (p Paginator[T]) PrepareQuery(q sq.SelectBuilder, page *Page) ([]T, sq.Sele return make([]T, 0, limit+1), q } +// Paginate returns offset-paginated rows and the page populated with More. +func (p Paginator[T]) Paginate(ctx context.Context, query *Querier, q sq.SelectBuilder, page *Page) ([]T, *Page, error) { + if page == nil { + page = &Page{} + } + result, q := p.PrepareQuery(q, page) + if err := query.GetAll(ctx, q, &result); err != nil { + return nil, nil, err + } + return p.PrepareResult(result, page), page, nil +} + func (p Paginator[T]) PrepareRaw(q string, args []any, page *Page) ([]T, string, []any) { if page == nil { page = &Page{} diff --git a/tests/cursor_test.go b/tests/cursor_test.go index 3a00711..14f531c 100644 --- a/tests/cursor_test.go +++ b/tests/cursor_test.go @@ -25,11 +25,11 @@ func (c *articleCursor) From(article *Article) error { return nil } -func TestCursorPaginatorListReturnsPage(t *testing.T) { +func TestCursorPaginatorPaginateReturnsPage(t *testing.T) { ctx := t.Context() db := initDB(DB) - account := &Account{Name: "CursorPaginatorList Account"} + account := &Account{Name: "CursorPaginatorPaginate Account"} err := db.Accounts.Save(ctx, account) require.NoError(t, err) @@ -48,7 +48,7 @@ func TestCursorPaginatorListReturnsPage(t *testing.T) { From("articles"). Where(sq.Eq{"account_id": account.ID}) - first, firstPage, err := paginator.List(ctx, db.Query, q, nil) + first, firstPage, err := paginator.Paginate(ctx, db.Query, q, nil) require.NoError(t, err) require.Len(t, first, 2) require.NotNil(t, firstPage) @@ -59,7 +59,7 @@ func TestCursorPaginatorListReturnsPage(t *testing.T) { page := &pgkit.Page{ Cursor: firstPage.NextCursor, } - second, secondPage, err := paginator.List(ctx, db.Query, q, page) + second, secondPage, err := paginator.Paginate(ctx, db.Query, q, page) require.NoError(t, err) require.Len(t, second, 2) require.Same(t, page, secondPage) @@ -72,3 +72,48 @@ func TestCursorPaginatorListReturnsPage(t *testing.T) { } } } + +func TestPaginatorPaginateReturnsPage(t *testing.T) { + ctx := t.Context() + db := initDB(DB) + + account := &Account{Name: "PaginatorPaginate Account"} + err := db.Accounts.Save(ctx, account) + require.NoError(t, err) + + for range 5 { + err := db.Articles.Save(ctx, &Article{ + AccountID: account.ID, + Author: "Offset Author", + }) + require.NoError(t, err) + } + + paginator := pgkit.NewPaginator[*Article](pgkit.WithDefaultSize(2)) + q := db.SQL.Select("*"). + From("articles"). + Where(sq.Eq{"account_id": account.ID}) + + first, firstPage, err := paginator.Paginate(ctx, db.Query, q, nil) + require.NoError(t, err) + require.Len(t, first, 2) + require.NotNil(t, firstPage) + require.Equal(t, uint32(2), firstPage.Size) + require.Equal(t, uint32(1), firstPage.Page) + require.True(t, firstPage.More) + + page := &pgkit.Page{Page: 2} + second, secondPage, err := paginator.Paginate(ctx, db.Query, q, page) + require.NoError(t, err) + require.Len(t, second, 2) + require.Same(t, page, secondPage) + require.Equal(t, uint32(2), secondPage.Size) + require.Equal(t, uint32(2), secondPage.Page) + require.True(t, secondPage.More) + + for _, a := range first { + for _, b := range second { + require.NotEqual(t, a.ID, b.ID, "offset pages should not overlap") + } + } +}