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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
638 changes: 407 additions & 231 deletions MIGRATION.md

Large diffs are not rendered by default.

228 changes: 130 additions & 98 deletions README.md

Large diffs are not rendered by default.

196 changes: 112 additions & 84 deletions connection_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package paging_test

import (
"context"
"fmt"

. "github.com/onsi/ginkgo/v2"
Expand All @@ -25,7 +26,42 @@ type DomainUser struct {
EmailAddr string
}

// mockOffsetFetcher creates a simple in-memory fetcher for testing
func mockOffsetFetcher(allUsers []DBUser, totalCount int64) paging.Fetcher[DBUser] {
return &offsetTestFetcher{
allUsers: allUsers,
totalCount: totalCount,
}
}

type offsetTestFetcher struct {
allUsers []DBUser
totalCount int64
}

func (f *offsetTestFetcher) Fetch(ctx context.Context, params paging.FetchParams) ([]DBUser, error) {
start := params.Offset
end := start + params.Limit
if start >= len(f.allUsers) {
return []DBUser{}, nil
}
if end > len(f.allUsers) {
end = len(f.allUsers)
}
return f.allUsers[start:end], nil
}

func (f *offsetTestFetcher) Count(ctx context.Context, params paging.FetchParams) (int64, error) {
return f.totalCount, nil
}

var _ = Describe("Connection and Edge", func() {
var ctx context.Context

BeforeEach(func() {
ctx = context.Background()
})

Describe("BuildConnection", func() {
It("should build a connection with edges and nodes", func() {
// Setup: Create mock database records
Expand Down Expand Up @@ -146,20 +182,27 @@ var _ = Describe("Connection and Edge", func() {

Describe("offset.BuildConnection", func() {
It("should build connection with offset-based cursors", func() {
// Setup: Create paginator
first := 2
pageArgs := &paging.PageArgs{
First: &first,
}
totalCount := int64(10)
paginator := offset.New(pageArgs, totalCount)

// Setup: Mock database records
dbUsers := []DBUser{
// Setup: Create mock data
allUsers := []DBUser{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
{ID: 3, Name: "Charlie", Email: "charlie@example.com"},
{ID: 4, Name: "Diana", Email: "diana@example.com"},
{ID: 5, Name: "Eve", Email: "eve@example.com"},
}

// Create paginator with fetcher
fetcher := mockOffsetFetcher(allUsers, 10)
paginator := offset.New(fetcher)

// Paginate first page (2 items)
first := 2
pageArgs := &paging.PageArgs{First: &first}

page, err := paginator.Paginate(ctx, pageArgs)
Expect(err).ToNot(HaveOccurred())
Expect(page.Nodes).To(HaveLen(2))

// Setup: Transform function
transform := func(db DBUser) (*DomainUser, error) {
return &DomainUser{
Expand All @@ -170,7 +213,7 @@ var _ = Describe("Connection and Edge", func() {
}

// Execute: Build connection using offset helper
conn, err := offset.BuildConnection(paginator, dbUsers, transform)
conn, err := offset.BuildConnection(page, transform)

// Assert: No error
Expect(err).ToNot(HaveOccurred())
Expand All @@ -183,8 +226,6 @@ var _ = Describe("Connection and Edge", func() {

// Assert: Edges have sequential cursors
Expect(conn.Edges).To(HaveLen(2))
// First item at offset 0 → cursor encodes offset 1
// Second item at offset 1 → cursor encodes offset 2
cursor1 := offset.DecodeCursor(&conn.Edges[0].Cursor)
cursor2 := offset.DecodeCursor(&conn.Edges[1].Cursor)
Expect(cursor1).To(Equal(1))
Expand All @@ -198,21 +239,32 @@ var _ = Describe("Connection and Edge", func() {
})

It("should handle second page with offset", func() {
// Setup: Second page starting at offset 2
// Setup: Create mock data
allUsers := []DBUser{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
{ID: 3, Name: "Charlie", Email: "charlie@example.com"},
{ID: 4, Name: "Diana", Email: "diana@example.com"},
{ID: 5, Name: "Eve", Email: "eve@example.com"},
}

// Create paginator
fetcher := mockOffsetFetcher(allUsers, 10)
paginator := offset.New(fetcher)

// Paginate second page (starting at offset 2)
first := 2
cursor := offset.EncodeCursor(2)
pageArgs := &paging.PageArgs{
First: &first,
After: cursor,
}
totalCount := int64(10)
paginator := offset.New(pageArgs, totalCount)

// Setup: Mock records for second page
dbUsers := []DBUser{
{ID: 3, Name: "Charlie", Email: "charlie@example.com"},
{ID: 4, Name: "Diana", Email: "diana@example.com"},
}
page, err := paginator.Paginate(ctx, pageArgs)
Expect(err).ToNot(HaveOccurred())
Expect(page.Nodes).To(HaveLen(2))
Expect(page.Nodes[0].ID).To(Equal(3)) // 3rd user (offset 2)
Expect(page.Nodes[1].ID).To(Equal(4)) // 4th user (offset 3)

transform := func(db DBUser) (*DomainUser, error) {
return &DomainUser{
Expand All @@ -221,7 +273,7 @@ var _ = Describe("Connection and Edge", func() {
}, nil
}

conn, err := offset.BuildConnection(paginator, dbUsers, transform)
conn, err := offset.BuildConnection(page, transform)

Expect(err).ToNot(HaveOccurred())

Expand All @@ -235,79 +287,55 @@ var _ = Describe("Connection and Edge", func() {

Describe("Real-world use case", func() {
It("should eliminate repository boilerplate", func() {
// This test demonstrates the before/after from the research document

// BEFORE: Manual boilerplate (what users had to write)
beforeConnection := func(dbUsers []DBUser, paginator offset.Paginator) (*paging.Connection[*DomainUser], error) {
result := &paging.Connection[*DomainUser]{
PageInfo: paginator.PageInfo,
}

for i, row := range dbUsers {
// Manual transformation
user := &DomainUser{
ID: fmt.Sprintf("user-%d", row.ID),
FullName: row.Name,
EmailAddr: row.Email,
}

// Manual cursor encoding
cursor := *offset.EncodeCursor(paginator.Offset + i + 1)

// Manual edge building
result.Edges = append(result.Edges, paging.Edge[*DomainUser]{
Cursor: cursor,
Node: user,
})

// Manual nodes building
result.Nodes = append(result.Nodes, user)
}

return result, nil
// Setup: Create mock data
allUsers := []DBUser{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
{ID: 3, Name: "Charlie", Email: "charlie@example.com"},
{ID: 4, Name: "Diana", Email: "diana@example.com"},
{ID: 5, Name: "Eve", Email: "eve@example.com"},
}

// AFTER: Using BuildConnection (new API)
afterConnection := func(dbUsers []DBUser, paginator offset.Paginator) (*paging.Connection[*DomainUser], error) {
return offset.BuildConnection(paginator, dbUsers, func(db DBUser) (*DomainUser, error) {
return &DomainUser{
ID: fmt.Sprintf("user-%d", db.ID),
FullName: db.Name,
EmailAddr: db.Email,
}, nil
})
}
fetcher := mockOffsetFetcher(allUsers, 10)
paginator := offset.New(fetcher)

// Test: Both approaches produce identical results
first := 3
pageArgs := &paging.PageArgs{First: &first}
totalCount := int64(10)
paginator := offset.New(pageArgs, totalCount)

dbUsers := []DBUser{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
{ID: 3, Name: "Charlie", Email: "charlie@example.com"},
}
page, err := paginator.Paginate(ctx, pageArgs)
Expect(err).ToNot(HaveOccurred())

beforeResult, beforeErr := beforeConnection(dbUsers, paginator)
afterResult, afterErr := afterConnection(dbUsers, paginator)
// AFTER: Using BuildConnection (new API)
// This is now the ONLY way to build a connection
conn, err := offset.BuildConnection(page, func(db DBUser) (*DomainUser, error) {
return &DomainUser{
ID: fmt.Sprintf("user-%d", db.ID),
FullName: db.Name,
EmailAddr: db.Email,
}, nil
})

// Assert: Succeeds
Expect(err).ToNot(HaveOccurred())

// Assert: Both succeed
Expect(beforeErr).ToNot(HaveOccurred())
Expect(afterErr).ToNot(HaveOccurred())
// Assert: Results are correct
Expect(conn.Nodes).To(HaveLen(3))
Expect(conn.Edges).To(HaveLen(3))

// Assert: Results are identical
Expect(afterResult.Nodes).To(HaveLen(len(beforeResult.Nodes)))
Expect(afterResult.Edges).To(HaveLen(len(beforeResult.Edges)))
Expect(conn.Nodes[0].ID).To(Equal("user-1"))
Expect(conn.Nodes[1].ID).To(Equal("user-2"))
Expect(conn.Nodes[2].ID).To(Equal("user-3"))

for i := range beforeResult.Nodes {
Expect(afterResult.Nodes[i].ID).To(Equal(beforeResult.Nodes[i].ID))
Expect(afterResult.Edges[i].Cursor).To(Equal(beforeResult.Edges[i].Cursor))
}
// Verify cursors
cursor1 := offset.DecodeCursor(&conn.Edges[0].Cursor)
cursor2 := offset.DecodeCursor(&conn.Edges[1].Cursor)
cursor3 := offset.DecodeCursor(&conn.Edges[2].Cursor)
Expect(cursor1).To(Equal(1))
Expect(cursor2).To(Equal(2))
Expect(cursor3).To(Equal(3))

// The key difference: AFTER is 1 line vs BEFORE is 15+ lines
// This is the 60-80% boilerplate reduction mentioned in the research
// The key difference: BuildConnection is 1 line vs manual boilerplate of 15+ lines
// This achieves the 60-80% boilerplate reduction mentioned in the research
})
})
})
Loading
Loading