Initial commit
This commit is contained in:
176
pkg/pagination/pagination.go
Normal file
176
pkg/pagination/pagination.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Package pagination provides pagination support for API responses.
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PaginatedResponse represents a paginated response from the API.
|
||||
type PaginatedResponse[T any] struct {
|
||||
// Results contains the items in this page
|
||||
Results []T
|
||||
|
||||
// Count is the total number of items (if available)
|
||||
Count int
|
||||
|
||||
// Limit is the maximum number of items per page
|
||||
Limit int
|
||||
|
||||
// Offset is the offset of the first item in this page
|
||||
Offset int
|
||||
|
||||
// HasMore indicates if there are more items to fetch
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// FetchFunc is a function that fetches a page of results.
|
||||
type FetchFunc[T any] func(ctx context.Context, offset, limit int) (*PaginatedResponse[T], error)
|
||||
|
||||
// Iterator provides iteration over paginated results.
|
||||
type Iterator[T any] struct {
|
||||
fetchFunc FetchFunc[T]
|
||||
limit int
|
||||
offset int
|
||||
current *PaginatedResponse[T]
|
||||
index int
|
||||
err error
|
||||
done bool
|
||||
}
|
||||
|
||||
// NewIterator creates a new pagination iterator.
|
||||
func NewIterator[T any](fetchFunc FetchFunc[T], limit int) *Iterator[T] {
|
||||
if limit <= 0 {
|
||||
limit = 100 // Default page size
|
||||
}
|
||||
return &Iterator[T]{
|
||||
fetchFunc: fetchFunc,
|
||||
limit: limit,
|
||||
offset: 0,
|
||||
index: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// Next advances the iterator to the next item.
|
||||
// It returns true if there is an item available, false otherwise.
|
||||
func (i *Iterator[T]) Next(ctx context.Context) bool {
|
||||
if i.done || i.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we don't have a current page or we've reached the end of it, fetch the next page
|
||||
if i.current == nil || i.index >= len(i.current.Results)-1 {
|
||||
// Check if we know there are no more pages
|
||||
if i.current != nil && !i.current.HasMore {
|
||||
i.done = true
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch the next page
|
||||
page, err := i.fetchFunc(ctx, i.offset, i.limit)
|
||||
if err != nil {
|
||||
i.err = err
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the page is empty
|
||||
if page == nil || len(page.Results) == 0 {
|
||||
i.done = true
|
||||
return false
|
||||
}
|
||||
|
||||
i.current = page
|
||||
i.index = -1
|
||||
i.offset += i.limit
|
||||
}
|
||||
|
||||
// Move to the next item in the current page
|
||||
i.index++
|
||||
return i.index < len(i.current.Results)
|
||||
}
|
||||
|
||||
// Value returns the current item.
|
||||
// It should only be called after Next returns true.
|
||||
func (i *Iterator[T]) Value() *T {
|
||||
if i.current == nil || i.index < 0 || i.index >= len(i.current.Results) {
|
||||
return nil
|
||||
}
|
||||
return &i.current.Results[i.index]
|
||||
}
|
||||
|
||||
// Err returns any error that occurred during iteration.
|
||||
func (i *Iterator[T]) Err() error {
|
||||
return i.err
|
||||
}
|
||||
|
||||
// Reset resets the iterator to the beginning.
|
||||
func (i *Iterator[T]) Reset() {
|
||||
i.offset = 0
|
||||
i.index = -1
|
||||
i.current = nil
|
||||
i.err = nil
|
||||
i.done = false
|
||||
}
|
||||
|
||||
// Collect fetches all items from all pages and returns them as a slice.
|
||||
// Warning: This can be memory intensive for large result sets.
|
||||
func (i *Iterator[T]) Collect(ctx context.Context) ([]T, error) {
|
||||
var results []T
|
||||
for i.Next(ctx) {
|
||||
if item := i.Value(); item != nil {
|
||||
results = append(results, *item)
|
||||
}
|
||||
}
|
||||
if err := i.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to collect all items: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CollectWithLimit fetches items up to a maximum count.
|
||||
func (i *Iterator[T]) CollectWithLimit(ctx context.Context, maxItems int) ([]T, error) {
|
||||
var results []T
|
||||
count := 0
|
||||
for i.Next(ctx) && count < maxItems {
|
||||
if item := i.Value(); item != nil {
|
||||
results = append(results, *item)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if err := i.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to collect items: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Page represents a single page of results with metadata.
|
||||
type Page[T any] struct {
|
||||
Items []T
|
||||
Offset int
|
||||
Limit int
|
||||
Total int
|
||||
}
|
||||
|
||||
// HasNextPage returns true if there are more pages after this one.
|
||||
func (p *Page[T]) HasNextPage() bool {
|
||||
return p.Offset+len(p.Items) < p.Total
|
||||
}
|
||||
|
||||
// HasPrevPage returns true if there are pages before this one.
|
||||
func (p *Page[T]) HasPrevPage() bool {
|
||||
return p.Offset > 0
|
||||
}
|
||||
|
||||
// NextOffset returns the offset for the next page.
|
||||
func (p *Page[T]) NextOffset() int {
|
||||
return p.Offset + p.Limit
|
||||
}
|
||||
|
||||
// PrevOffset returns the offset for the previous page.
|
||||
func (p *Page[T]) PrevOffset() int {
|
||||
offset := p.Offset - p.Limit
|
||||
if offset < 0 {
|
||||
return 0
|
||||
}
|
||||
return offset
|
||||
}
|
||||
339
pkg/pagination/pagination_test.go
Normal file
339
pkg/pagination/pagination_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIterator(t *testing.T) {
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
limit int
|
||||
wantLimit int
|
||||
}{
|
||||
{"default limit", 0, 100},
|
||||
{"custom limit", 50, 50},
|
||||
{"negative limit", -10, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iter := NewIterator(fetchFunc, tt.limit)
|
||||
if iter.limit != tt.wantLimit {
|
||||
t.Errorf("limit = %d, want %d", iter.limit, tt.wantLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_Next(t *testing.T) {
|
||||
items := []string{"item1", "item2", "item3", "item4", "item5"}
|
||||
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
if offset >= len(items) {
|
||||
return &PaginatedResponse[string]{
|
||||
Results: []string{},
|
||||
Count: len(items),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
return &PaginatedResponse[string]{
|
||||
Results: items[offset:end],
|
||||
Count: len(items),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
HasMore: end < len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 2)
|
||||
|
||||
var results []string
|
||||
for iter.Next(ctx) {
|
||||
if item := iter.Value(); item != nil {
|
||||
results = append(results, *item)
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != len(items) {
|
||||
t.Errorf("got %d items, want %d", len(results), len(items))
|
||||
}
|
||||
|
||||
for i, want := range items {
|
||||
if i >= len(results) || results[i] != want {
|
||||
t.Errorf("item[%d] = %v, want %v", i, results[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_Error(t *testing.T) {
|
||||
expectedErr := errors.New("fetch error")
|
||||
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
return nil, expectedErr
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 10)
|
||||
|
||||
if iter.Next(ctx) {
|
||||
t.Error("Next should return false on error")
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != expectedErr {
|
||||
t.Errorf("Err() = %v, want %v", err, expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_EmptyResults(t *testing.T) {
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
return &PaginatedResponse[string]{
|
||||
Results: []string{},
|
||||
Count: 0,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 10)
|
||||
|
||||
if iter.Next(ctx) {
|
||||
t.Error("Next should return false for empty results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_Reset(t *testing.T) {
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
if offset >= len(items) {
|
||||
return &PaginatedResponse[string]{
|
||||
Results: []string{},
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaginatedResponse[string]{
|
||||
Results: items[offset:],
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 10)
|
||||
|
||||
// First iteration
|
||||
count1 := 0
|
||||
for iter.Next(ctx) {
|
||||
count1++
|
||||
}
|
||||
|
||||
// Reset and iterate again
|
||||
iter.Reset()
|
||||
count2 := 0
|
||||
for iter.Next(ctx) {
|
||||
count2++
|
||||
}
|
||||
|
||||
if count1 != count2 {
|
||||
t.Errorf("counts don't match after reset: %d vs %d", count1, count2)
|
||||
}
|
||||
if count1 != len(items) {
|
||||
t.Errorf("got %d items, want %d", count1, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_Collect(t *testing.T) {
|
||||
items := []string{"item1", "item2", "item3", "item4", "item5"}
|
||||
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
if offset >= len(items) {
|
||||
return &PaginatedResponse[string]{
|
||||
Results: []string{},
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
return &PaginatedResponse[string]{
|
||||
Results: items[offset:end],
|
||||
HasMore: end < len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 2)
|
||||
|
||||
results, err := iter.Collect(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != len(items) {
|
||||
t.Errorf("got %d items, want %d", len(results), len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterator_CollectWithLimit(t *testing.T) {
|
||||
items := []string{"item1", "item2", "item3", "item4", "item5"}
|
||||
|
||||
fetchFunc := func(ctx context.Context, offset, limit int) (*PaginatedResponse[string], error) {
|
||||
if offset >= len(items) {
|
||||
return &PaginatedResponse[string]{
|
||||
Results: []string{},
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
return &PaginatedResponse[string]{
|
||||
Results: items[offset:end],
|
||||
HasMore: end < len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
iter := NewIterator(fetchFunc, 2)
|
||||
|
||||
maxItems := 3
|
||||
results, err := iter.CollectWithLimit(ctx, maxItems)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != maxItems {
|
||||
t.Errorf("got %d items, want %d", len(results), maxItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage_HasNextPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page Page[string]
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "has next page",
|
||||
page: Page[string]{
|
||||
Items: []string{"a", "b"},
|
||||
Offset: 0,
|
||||
Limit: 2,
|
||||
Total: 5,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no next page",
|
||||
page: Page[string]{
|
||||
Items: []string{"d", "e"},
|
||||
Offset: 3,
|
||||
Limit: 2,
|
||||
Total: 5,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.page.HasNextPage(); got != tt.want {
|
||||
t.Errorf("HasNextPage() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage_HasPrevPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page Page[string]
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "has previous page",
|
||||
page: Page[string]{
|
||||
Offset: 2,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no previous page",
|
||||
page: Page[string]{
|
||||
Offset: 0,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.page.HasPrevPage(); got != tt.want {
|
||||
t.Errorf("HasPrevPage() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage_NextOffset(t *testing.T) {
|
||||
page := Page[string]{
|
||||
Offset: 10,
|
||||
Limit: 5,
|
||||
}
|
||||
|
||||
if got := page.NextOffset(); got != 15 {
|
||||
t.Errorf("NextOffset() = %v, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage_PrevOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int
|
||||
limit int
|
||||
want int
|
||||
}{
|
||||
{"normal case", 10, 5, 5},
|
||||
{"at beginning", 3, 5, 0},
|
||||
{"exact boundary", 5, 5, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page := Page[string]{
|
||||
Offset: tt.offset,
|
||||
Limit: tt.limit,
|
||||
}
|
||||
if got := page.PrevOffset(); got != tt.want {
|
||||
t.Errorf("PrevOffset() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user