diff --git a/cursor/paginator.go b/cursor/paginator.go index da574f2..8094a38 100644 --- a/cursor/paginator.go +++ b/cursor/paginator.go @@ -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: @@ -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 } @@ -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 } @@ -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. // diff --git a/cursor/schema.go b/cursor/schema.go index 80c629d..a1121e9 100644 --- a/cursor/schema.go +++ b/cursor/schema.go @@ -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 { @@ -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 } diff --git a/offset/cursor.go b/offset/cursor.go index 4803ddf..9a8690d 100644 --- a/offset/cursor.go +++ b/offset/cursor.go @@ -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) } diff --git a/models.go b/page_args.go similarity index 74% rename from models.go rename to page_args.go index 038e4c3..3fd9cb4 100644 --- a/models.go +++ b/page_args.go @@ -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) -} diff --git a/page_args_test.go b/page_args_test.go index 28158ae..15cb69f 100644 --- a/page_args_test.go +++ b/page_args_test.go @@ -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()) + }) +}) diff --git a/page_info.go b/page_info.go new file mode 100644 index 0000000..a599bf5 --- /dev/null +++ b/page_info.go @@ -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 }, + } +} diff --git a/quotafill/quotafill.go b/quotafill/quotafill.go index 2329443..4d206ac 100644 --- a/quotafill/quotafill.go +++ b/quotafill/quotafill.go @@ -186,15 +186,7 @@ func (w *Wrapper[T]) fetchIteration( // Get encoder from schema for the current args var cursorPos *paging.CursorPosition if state.currentCursor != nil && w.schema != nil { - // Build temporary args with current cursor for encoder - cursorArgs := &paging.PageArgs{} - if args != nil && args.GetSortBy() != nil { - cursorArgs = &paging.PageArgs{ - SortBy: args.GetSortBy(), - } - } - - encoder, err := w.schema.EncoderFor(cursorArgs) + encoder, err := w.schema.EncoderFor(argsForEncoder(args)) if err != nil { state.lastError = fmt.Errorf("get encoder (iteration %d): %w", state.iteration+1, err) return "" @@ -210,11 +202,7 @@ func (w *Wrapper[T]) fetchIteration( // Get orderBy from schema var orderBy []paging.Sort if w.schema != nil { - var sortBy []paging.Sort - if args != nil && args.GetSortBy() != nil { - sortBy = args.GetSortBy() - } - orderBy = w.schema.BuildOrderBy(sortBy) + orderBy = w.schema.BuildOrderBy(getSortBy(args)) } fetchParams := paging.FetchParams{ @@ -257,15 +245,7 @@ func (w *Wrapper[T]) fetchIteration( // Encode cursor from last EXAMINED item (not last filtered item) // This ensures we continue from where we left off in the database scan if w.schema != nil && len(trimmedItems) > 0 { - // Build temporary args for encoder - cursorArgs := &paging.PageArgs{} - if args != nil && args.GetSortBy() != nil { - cursorArgs = &paging.PageArgs{ - SortBy: args.GetSortBy(), - } - } - - encoder, err := w.schema.EncoderFor(cursorArgs) + encoder, err := w.schema.EncoderFor(argsForEncoder(args)) if err != nil { state.lastError = fmt.Errorf("get encoder for cursor (iteration %d): %w", state.iteration, err) return "" @@ -315,6 +295,22 @@ func stringPtr(s string) *string { return &s } +// argsForEncoder extracts just the SortBy from args for encoder creation. +func argsForEncoder(args *paging.PageArgs) *paging.PageArgs { + if args == nil || args.GetSortBy() == nil { + return &paging.PageArgs{} + } + return &paging.PageArgs{SortBy: args.GetSortBy()} +} + +// getSortBy safely extracts SortBy from args. +func getSortBy(args *paging.PageArgs) []paging.Sort { + if args == nil || args.GetSortBy() == nil { + return nil + } + return args.GetSortBy() +} + func buildPageInfo[T any]( args *paging.PageArgs, hasNextPage bool, @@ -327,14 +323,7 @@ func buildPageInfo[T any]( if schema == nil || len(items) == 0 { return nil, nil } - // Build temporary args for encoder - cursorArgs := &paging.PageArgs{} - if args != nil && args.GetSortBy() != nil { - cursorArgs = &paging.PageArgs{ - SortBy: args.GetSortBy(), - } - } - encoder, err := schema.EncoderFor(cursorArgs) + encoder, err := schema.EncoderFor(argsForEncoder(args)) if err != nil { return nil, err } @@ -344,14 +333,7 @@ func buildPageInfo[T any]( if schema == nil || len(items) == 0 { return nil, nil } - // Build temporary args for encoder - cursorArgs := &paging.PageArgs{} - if args != nil && args.GetSortBy() != nil { - cursorArgs = &paging.PageArgs{ - SortBy: args.GetSortBy(), - } - } - encoder, err := schema.EncoderFor(cursorArgs) + encoder, err := schema.EncoderFor(argsForEncoder(args)) if err != nil { return nil, err } diff --git a/sqlboiler/cursor.go b/sqlboiler/cursor.go index 330f833..bd7355d 100644 --- a/sqlboiler/cursor.go +++ b/sqlboiler/cursor.go @@ -139,7 +139,7 @@ func rawWhereClause(clause string, args []interface{}) qm.QueryMod { } // convertValueForSQL converts JSON-decoded values to proper SQL types. -// JSON unmarshaling can change types (e.g., int → float64), so we normalize them here. +// JSON unmarshaling can change types (e.g., int to float64), so we normalize them here. func convertValueForSQL(val any) interface{} { switch v := val.(type) { case string: @@ -149,26 +149,10 @@ func convertValueForSQL(val any) interface{} { } return v - case float64: - // JSON numbers are always float64, but we might want int for integer columns - // For now, pass as-is and let PostgreSQL handle the conversion + case float64, int, int64, bool, time.Time, nil: + // Pass through types that PostgreSQL handles natively return v - case int: - return v - - case int64: - return v - - case bool: - return v - - case time.Time: - return v - - case nil: - return nil - default: // For unknown types, convert to string return fmt.Sprintf("%v", v)