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/3] 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/3] 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/3] 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 }