Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 62 additions & 83 deletions cursor/paginator.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,51 @@ type Paginator struct {
encoder interface{} // Stored as interface{} to avoid making Paginator generic
}

// parsedParams holds the common parsed state from PageArgs and Schema.
type parsedParams[T any] struct {
encoder paging.CursorEncoder[T]
orderBy []paging.Sort
limit int
cursor *paging.CursorPosition
}

// parsePageArgs extracts and validates common parameters from PageArgs and Schema.
func parsePageArgs[T any](page PageArgs, schema *Schema[T], defaultLimit *int) (parsedParams[T], error) {
encoder, err := schema.EncoderFor(page)
if err != nil {
return parsedParams[T]{}, err
}

var sortBy []paging.Sort
if page != nil && page.GetSortBy() != nil {
sortBy = page.GetSortBy()
}
orderBy := schema.BuildOrderBy(sortBy)

limit := defaultLimitVal
if defaultLimit != nil {
limit = *defaultLimit
}
if page != nil && page.GetFirst() != nil && *page.GetFirst() > 0 {
limit = *page.GetFirst()
}
if limit == 0 {
limit = defaultLimitVal
}

var cursor *paging.CursorPosition
if page != nil && page.GetAfter() != nil {
cursor, _ = encoder.Decode(*page.GetAfter())
}

return parsedParams[T]{
encoder: encoder,
orderBy: orderBy,
limit: limit,
cursor: cursor,
}, nil
}

// New creates a new cursor paginator using a Schema.
//
// Parameters:
Expand Down Expand Up @@ -82,59 +127,33 @@ func New[T any](
items []T,
defaultLimit ...*int,
) (Paginator, error) {
// Validate PageArgs and get encoder from schema
encoder, err := schema.EncoderFor(page)
if err != nil {
return Paginator{}, err
}

// Build orderBy from schema (includes fixed fields)
var sortBy []paging.Sort
if page != nil && page.GetSortBy() != nil {
sortBy = page.GetSortBy()
}
orderBy := schema.BuildOrderBy(sortBy)

// Determine limit (same as offset pagination)
limit := defaultLimitVal
if len(defaultLimit) > 0 && defaultLimit[0] != nil {
limit = *defaultLimit[0]
var defLimit *int
if len(defaultLimit) > 0 {
defLimit = defaultLimit[0]
}

if page != nil && page.GetFirst() != nil && *page.GetFirst() > 0 {
limit = *page.GetFirst()
}

// Ensure limit is never 0 to avoid divide by zero
if limit == 0 {
limit = defaultLimitVal
}

// Decode cursor from After field
var cursor *paging.CursorPosition
if page != nil && page.GetAfter() != nil {
cursor, _ = encoder.Decode(*page.GetAfter())
params, err := parsePageArgs(page, schema, defLimit)
if err != nil {
return Paginator{}, err
}

// N+1 pattern: Check if we got more items than requested
// If caller fetched LIMIT+1 and we got LIMIT+1, there's a next page
hasNextPage := len(items) > limit
hasNextPage := len(items) > params.limit

// Trim items to the requested limit
trimmedItems := items
if hasNextPage {
trimmedItems = items[:limit]
trimmedItems = items[:params.limit]
}

// Build PageInfo with trimmed items and accurate hasNextPage
pageInfo := newCursorBasedPageInfo(encoder, trimmedItems, limit, cursor, hasNextPage)
pageInfo := newCursorBasedPageInfo(params.encoder, trimmedItems, params.limit, params.cursor, hasNextPage)

return Paginator{
limit: limit,
cursor: cursor,
limit: params.limit,
cursor: params.cursor,
PageInfo: pageInfo,
orderBy: orderBy,
encoder: encoder, // Store encoder for BuildConnection
orderBy: params.orderBy,
encoder: params.encoder,
}, nil
}

Expand All @@ -144,37 +163,15 @@ func BuildFetchParams[T any](
page PageArgs,
schema *Schema[T],
) (paging.FetchParams, error) {
// Validate PageArgs and get encoder from schema
encoder, err := schema.EncoderFor(page)
params, err := parsePageArgs(page, schema, nil)
if err != nil {
return paging.FetchParams{}, err
}

// Build orderBy from schema (includes fixed fields)
var sortBy []paging.Sort
if page != nil && page.GetSortBy() != nil {
sortBy = page.GetSortBy()
}
orderBy := schema.BuildOrderBy(sortBy)

limit := defaultLimitVal
if page != nil && page.GetFirst() != nil && *page.GetFirst() > 0 {
limit = *page.GetFirst()
}

if limit == 0 {
limit = defaultLimitVal
}

var cursor *paging.CursorPosition
if page != nil && page.GetAfter() != nil {
cursor, _ = encoder.Decode(*page.GetAfter())
}

return paging.FetchParams{
Limit: limit + 1,
Cursor: cursor,
OrderBy: orderBy,
Limit: params.limit + 1, // N+1 for HasNextPage detection
Cursor: params.cursor,
OrderBy: params.orderBy,
}, nil
}

Expand Down Expand Up @@ -273,24 +270,6 @@ func BuildConnection[From any, To any](
)
}

// defaultOrderBy is the fallback when no sort columns are specified.
var defaultOrderBy = []paging.Sort{{Column: "created_at", Desc: true}}

// buildOrderBy constructs OrderBy directives from PageArgs.
// Defaults to created_at DESC if no sort columns are specified.
func buildOrderBy(page PageArgs) []paging.Sort {
if page == nil {
return defaultOrderBy
}

sortBy := page.GetSortBy()
if len(sortBy) == 0 {
return defaultOrderBy
}

return sortBy
}

// newCursorBasedPageInfo creates PageInfo for cursor-based pagination.
// It uses the fetched items to generate cursors and determine page boundaries.
//
Expand Down
44 changes: 12 additions & 32 deletions cursor/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,8 @@ func (s *Schema[T]) EncoderFor(pageArgs PageArgs) (paging.CursorEncoder[T], erro
// BuildOrderBy([{Column: "name", Desc: true}])
// // Returns: [tenant_id ASC, name DESC, id DESC]
func (s *Schema[T]) BuildOrderBy(userSorts []paging.Sort) []paging.Sort {
result := make([]paging.Sort, 0)

// Add fixed fields in declaration order, respecting their position relative to user-sortable fields
// We need to:
// 1. Add fixed fields that come before the first user-sortable field
// 2. Add user sorts
// 3. Add fixed fields that come after the last user-sortable field

// Find position of first and last user-sortable field
firstSortablePos := -1
lastSortablePos := -1
// Find position bounds of user-sortable fields
firstSortablePos, lastSortablePos := -1, -1
for _, spec := range s.allFields {
if !spec.isFixed {
if firstSortablePos == -1 {
Expand All @@ -188,38 +179,27 @@ func (s *Schema[T]) BuildOrderBy(userSorts []paging.Sort) []paging.Sort {

// Special case: No user-sortable fields registered (only fixed fields)
if firstSortablePos == -1 {
for _, spec := range s.fixedFields {
result = append(result, paging.Sort{
Column: spec.name,
Desc: bool(*spec.direction),
})
}
return result
}

// Add fixed fields that come before first sortable field (prepended)
for _, spec := range s.fixedFields {
if spec.position < firstSortablePos {
result = append(result, paging.Sort{
Column: spec.name,
Desc: bool(*spec.direction),
})
}
return s.fixedFieldsToSorts(func(*fieldSpec[T]) bool { return true })
}

// Add user sorts
// Build result: prepend fixed fields before first sortable, then user sorts, then append fixed fields after last sortable
result := s.fixedFieldsToSorts(func(spec *fieldSpec[T]) bool { return spec.position < firstSortablePos })
result = append(result, userSorts...)
result = append(result, s.fixedFieldsToSorts(func(spec *fieldSpec[T]) bool { return spec.position > lastSortablePos })...)
return result
}

// Add fixed fields that come after last sortable field (appended)
// fixedFieldsToSorts converts fixed fields matching the predicate to Sort directives.
func (s *Schema[T]) fixedFieldsToSorts(predicate func(*fieldSpec[T]) bool) []paging.Sort {
var result []paging.Sort
for _, spec := range s.fixedFields {
if spec.position > lastSortablePos {
if predicate(spec) {
result = append(result, paging.Sort{
Column: spec.name,
Desc: bool(*spec.direction),
})
}
}

return result
}

Expand Down
23 changes: 10 additions & 13 deletions offset/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,19 @@ func DecodeCursor(input *string) int {
return 0
}

var decoded []byte
var data []string
var err error

if decoded, err = base64.URLEncoding.DecodeString(*input); err != nil {
decoded, err := base64.URLEncoding.DecodeString(*input)
if err != nil {
return 0
}

if data = strings.Split(string(decoded), ":"); len(data) == 3 {
offset, err := strconv.ParseInt(data[2], 10, 32)

if err != nil {
return 0
}
return int(offset)
parts := strings.Split(string(decoded), ":")
if len(parts) != 3 {
return 0
}

return 0
offset, err := strconv.ParseInt(parts[2], 10, 32)
if err != nil {
return 0
}
return int(offset)
}
14 changes: 0 additions & 14 deletions models.go → page_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,3 @@ func (pa *PageArgs) GetAfter() *string {
func (pa *PageArgs) GetSortBy() []Sort {
return pa.SortBy
}

// PageInfo contains metadata about a paginated result set.
// It uses function fields to enable lazy evaluation of pagination metadata,
// which is useful when some information (like total count) may be expensive to compute.
//
// All functions return both a value and an error to support async computation
// or database queries that may fail.
type PageInfo struct {
TotalCount func() (*int, error)
HasPreviousPage func() (bool, error)
HasNextPage func() (bool, error)
StartCursor func() (*string, error)
EndCursor func() (*string, error)
}
26 changes: 26 additions & 0 deletions page_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,29 @@ var _ = Describe("PageArgs", func() {
})
})
})

var _ = Describe("NewEmptyPageInfo", func() {
It("should return empty PageInfo with nil/false values", func() {
pageInfo := paging.NewEmptyPageInfo()

totalCount, err := pageInfo.TotalCount()
Expect(err).ToNot(HaveOccurred())
Expect(totalCount).To(BeNil())

startCursor, err := pageInfo.StartCursor()
Expect(err).ToNot(HaveOccurred())
Expect(startCursor).To(BeNil())

endCursor, err := pageInfo.EndCursor()
Expect(err).ToNot(HaveOccurred())
Expect(endCursor).To(BeNil())

hasNext, err := pageInfo.HasNextPage()
Expect(err).ToNot(HaveOccurred())
Expect(hasNext).To(BeFalse())

hasPrev, err := pageInfo.HasPreviousPage()
Expect(err).ToNot(HaveOccurred())
Expect(hasPrev).To(BeFalse())
})
})
45 changes: 45 additions & 0 deletions page_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package paging

// PageInfo contains metadata about a paginated result set.
// It uses function fields to enable lazy evaluation of pagination metadata,
// which is useful when some information (like total count) may be expensive to compute.
//
// All functions return both a value and an error to support async computation
// or database queries that may fail.
type PageInfo struct {
TotalCount func() (*int, error)
HasPreviousPage func() (bool, error)
HasNextPage func() (bool, error)
StartCursor func() (*string, error)
EndCursor func() (*string, error)
}

// NewEmptyPageInfo returns an empty instance of PageInfo.
// This is useful when you need to satisfy PageInfo requirements but don't have
// pagination data yet (e.g., empty result sets, error cases, or placeholder responses).
//
// All functions return nil or false:
// - TotalCount: nil
// - StartCursor: nil
// - EndCursor: nil
// - HasNextPage: false
// - HasPreviousPage: false
//
// Example usage:
//
// if len(items) == 0 {
// return &Connection{
// Nodes: []Item{},
// Edges: []Edge[Item]{},
// PageInfo: NewEmptyPageInfo(),
// }, nil
// }
func NewEmptyPageInfo() PageInfo {
return PageInfo{
TotalCount: func() (*int, error) { return nil, nil },
StartCursor: func() (*string, error) { return nil, nil },
EndCursor: func() (*string, error) { return nil, nil },
HasNextPage: func() (bool, error) { return false, nil },
HasPreviousPage: func() (bool, error) { return false, nil },
}
}
Loading
Loading