relay is a comprehensive Go library for implementing Relay-style pagination with advanced features. Beyond supporting both keyset-based and offset-based pagination strategies, it provides powerful filtering capabilities, computed fields for database-level calculations, seamless gRPC/Protocol Buffers integration, and flexible cursor encryption options. Whether you're building REST APIs or gRPC services, relay helps you implement efficient, type-safe pagination with minimal boilerplate.
- Supports keyset-based and offset-based pagination: You can freely choose high-performance keyset pagination based on multiple indexed columns, or use offset pagination.
- Optional cursor encryption: Supports encrypting cursors using
GCM(AES)orBase64to ensure the security of pagination information. - Flexible query strategies: Optionally skip the
TotalCountquery to improve performance, especially in large datasets. - Non-generic support: Even without using Go generics, you can paginate using the
anytype for flexible use cases. - Computed fields: Add database-level calculated fields using SQL expressions for sorting and pagination.
- Powerful filtering: Type-safe filtering with support for comparison operators, string matching, logical combinations, and relationship filtering.
- gRPC/Protocol Buffers integration: Built-in utilities for parsing proto messages, including enums, order fields, filters, and pagination requests.
p := relay.New(
cursor.Base64(func(ctx context.Context, req *relay.ApplyCursorsRequest) (*relay.ApplyCursorsResponse[*User], error) {
// Offset-based pagination
// return gormrelay.NewOffsetAdapter[*User](db)(ctx, req)
// Keyset-based pagination
return gormrelay.NewKeysetAdapter[*User](db)(ctx, req)
}),
// defaultLimit / maxLimit
relay.EnsureLimits[*User](10, 100),
// Append primary sorting fields, if any are unspecified
relay.EnsurePrimaryOrderBy[*User](
relay.Order{Field: "ID", Direction: relay.OrderDirectionAsc},
relay.Order{Field: "Version", Direction: relay.OrderDirectionAsc},
),
)
conn, err := p.Paginate(
context.Background(),
// relay.WithSkip(context.Background(), relay.Skip{
// Edges: true,
// Nodes: true,
// PageInfo: true,
// TotalCount: true,
// }),
// Query first 10 records
&relay.PaginateRequest[*User]{
First: lo.ToPtr(10),
}
)If you need to encrypt cursors, you can use cursor.Base64 or cursor.GCM wrappers:
// Encode cursors with Base64
cursor.Base64(gormrelay.NewOffsetAdapter[*User](db))
// Encrypt cursors with GCM(AES)
gcm, err := cursor.NewGCM(encryptionKey)
require.NoError(t, err)
cursor.GCM(gcm)(gormrelay.NewKeysetAdapter[*User](db))If you do not use generics, you can create a paginator with the any type and combine it with the db.Model method:
p := relay.New(
func(ctx context.Context, req *relay.ApplyCursorsRequest) (*relay.ApplyCursorsResponse[any], error) {
// Since this is a generic function (T: any), we must call db.Model(x)
return gormrelay.NewKeysetAdapter[any](db.Model(&User{}))(ctx, req)
},
relay.EnsureLimits[any](10, 100),
relay.EnsurePrimaryOrderBy[any](relay.Order{Field: "ID", Direction: relay.OrderDirectionAsc}),
)
conn, err := p.Paginate(context.Background(), &relay.PaginateRequest[any]{
First: lo.ToPtr(10), // query first 10 records
})relay supports computed fields, allowing you to add SQL expressions calculated at the database level and use them for sorting and pagination.
import (
"github.com/theplant/relay/gormrelay"
)
p := relay.New(
gormrelay.NewKeysetAdapter[*User](
db,
gormrelay.WithComputed(&gormrelay.Computed[*User]{
Columns: gormrelay.NewComputedColumns(map[string]string{
"Priority": "CASE WHEN status = 'premium' THEN 1 WHEN status = 'vip' THEN 2 ELSE 3 END",
}),
Scanner: gormrelay.NewComputedScanner[*User],
}),
),
relay.EnsureLimits[*User](10, 100),
relay.EnsurePrimaryOrderBy[*User](
relay.Order{Field: "ID", Direction: relay.OrderDirectionAsc},
),
)
// Use computed field in ordering
conn, err := p.Paginate(context.Background(), &relay.PaginateRequest[*User]{
First: lo.ToPtr(10),
OrderBy: []relay.Order{
{Field: "Priority", Direction: relay.OrderDirectionAsc}, // Sort by computed field
{Field: "ID", Direction: relay.OrderDirectionAsc},
},
})NewComputedColumns
Helper function to create computed column definitions from SQL expressions:
gormrelay.NewComputedColumns(map[string]string{
"FieldName": "SQL expression",
})NewComputedScanner
Standard scanner function that handles result scanning and wrapping. This is the recommended implementation for most use cases:
gormrelay.NewComputedScanner[*User]Custom Scanner
For custom types or complex scenarios, implement your own Scanner function:
type Shop struct {
ID int
Name string
Priority int `gorm:"-"` // Computed field, not stored in DB
}
gormrelay.WithComputed(&gormrelay.Computed[*Shop]{
Columns: gormrelay.NewComputedColumns(map[string]string{
"Priority": "CASE WHEN name = 'premium' THEN 1 ELSE 2 END",
}),
Scanner: func(db *gorm.DB) (*gormrelay.ComputedScanner[*Shop], error) {
shops := []*Shop{}
return &gormrelay.ComputedScanner[*Shop]{
Destination: &shops,
Transform: func(computedResults []map[string]any) []cursor.Node[*Shop] {
return lo.Map(shops, func(s *Shop, i int) cursor.Node[*Shop] {
// Populate computed field
s.Priority = int(computedResults[i]["Priority"].(int32))
return gormrelay.NewComputedNode(s, computedResults[i])
})
},
}, nil
},
})p := relay.New(
gormrelay.NewKeysetAdapter[*User](
db,
gormrelay.WithComputed(&gormrelay.Computed[*User]{
Columns: gormrelay.NewComputedColumns(map[string]string{
"Score": "(points * 10 + bonus)",
"Rank": "CASE WHEN score > 100 THEN 'A' WHEN score > 50 THEN 'B' ELSE 'C' END",
}),
Scanner: gormrelay.NewComputedScanner[*User],
}),
),
relay.EnsureLimits[*User](10, 100),
relay.EnsurePrimaryOrderBy[*User](
relay.Order{Field: "ID", Direction: relay.OrderDirectionAsc},
),
)
// Multi-level sorting with computed fields
conn, err := p.Paginate(context.Background(), &relay.PaginateRequest[*User]{
First: lo.ToPtr(10),
OrderBy: []relay.Order{
{Field: "Rank", Direction: relay.OrderDirectionAsc},
{Field: "Score", Direction: relay.OrderDirectionDesc},
{Field: "ID", Direction: relay.OrderDirectionAsc},
},
})- Computed fields are calculated by the database, ensuring consistency and performance
- The computed values are automatically included in cursor serialization for pagination
- Field names in
NewComputedColumnsare converted to SQL aliases usingComputedFieldToColumnAlias - Both keyset and offset pagination support computed fields
For more details on computed fields design and common questions, see FAQ: Computed Fields.
relay provides powerful type-safe filtering capabilities through the filter and gormfilter packages.
import (
"github.com/theplant/relay/filter"
"github.com/theplant/relay/filter/gormfilter"
)
type UserFilter struct {
Name *filter.String
Age *filter.Int
}
db.Scopes(
gormfilter.Scope(&UserFilter{
Name: &filter.String{
Contains: lo.ToPtr("john"),
Fold: true, // case-insensitive
},
Age: &filter.Int{
Gte: lo.ToPtr(18),
},
}),
).Find(&users)The filter package provides the following types and operators:
String (filter.String / filter.ID)
Eq,Neq: Equal / Not equalLt,Lte,Gt,Gte: Less than, Less than or equal, Greater than, Greater than or equalIn,NotIn: In / Not in arrayContains,StartsWith,EndsWith: String pattern matchingFold: Case-insensitive comparison (works with all string operators)IsNull: Null check
Numeric (filter.Int / filter.Float)
Eq,Neq,Lt,Lte,Gt,Gte: Comparison operatorsIn,NotIn: In / Not in arrayIsNull: Null check
Boolean (filter.Boolean)
Eq,Neq: Equal / Not equalIsNull: Null check
Time (filter.Time)
Eq,Neq,Lt,Lte,Gt,Gte: Time comparisonIn,NotIn: In / Not in arrayIsNull: Null check
Filters support And, Or, and Not logical operators:
type UserFilter struct {
And []*UserFilter
Or []*UserFilter
Not *UserFilter
Name *filter.String
Age *filter.Int
}
// Complex filter example
db.Scopes(
gormfilter.Scope(&UserFilter{
Or: []*UserFilter{
{
Name: &filter.String{
StartsWith: lo.ToPtr("J"),
Fold: true,
},
},
{
Age: &filter.Int{
Gt: lo.ToPtr(30),
},
},
},
}),
).Find(&users)The filter supports filtering by BelongsTo/HasOne relationships with multi-level nesting:
type CountryFilter struct {
Code *filter.String
Name *filter.String
}
type CompanyFilter struct {
Name *filter.String
Country *CountryFilter // BelongsTo relationship
}
type UserFilter struct {
Age *filter.Int
Company *CompanyFilter // BelongsTo relationship
}
// Filter users by company's country
db.Scopes(
gormfilter.Scope(&UserFilter{
Age: &filter.Int{
Gte: lo.ToPtr(21),
},
Company: &CompanyFilter{
Name: &filter.String{
Contains: lo.ToPtr("Tech"),
},
Country: &CountryFilter{
Code: &filter.String{
Eq: lo.ToPtr("US"),
},
Name: &filter.String{
Eq: lo.ToPtr("United States"),
},
},
},
}),
).Find(&users)Filter and paginator can work together seamlessly:
import (
"github.com/theplant/relay"
"github.com/theplant/relay/cursor"
"github.com/theplant/relay/filter"
"github.com/theplant/relay/filter/gormfilter"
"github.com/theplant/relay/gormrelay"
)
type UserFilter struct {
Name *filter.String
Age *filter.Int
Company *CompanyFilter
}
// Create paginator with filter
p := relay.New(
cursor.Base64(func(ctx context.Context, req *relay.ApplyCursorsRequest) (*relay.ApplyCursorsResponse[*User], error) {
return gormrelay.NewKeysetAdapter[*User](
db.WithContext(ctx).Scopes(gormfilter.Scope(&UserFilter{
Age: &filter.Int{
Gte: lo.ToPtr(18),
},
Company: &CompanyFilter{
Name: &filter.String{
Contains: lo.ToPtr("Tech"),
Fold: true,
},
},
})),
)(ctx, req)
}),
relay.EnsureLimits[*User](10, 100),
relay.EnsurePrimaryOrderBy[*User](
relay.Order{Field: "ID", Direction: relay.OrderDirectionAsc},
),
)
conn, err := p.Paginate(context.Background(), &relay.PaginateRequest[*User]{
First: lo.ToPtr(10),
})Disable Relationship Filtering:
db.Scopes(
gormfilter.Scope(
userFilter,
gormfilter.WithDisableBelongsTo(),
gormfilter.WithDisableHasOne(),
// gormfilter.WithDisableRelationships(), // disable all relationships
),
).Find(&users)Relationship filters use IN subqueries, which are generally efficient for most use cases. Performance depends on:
- Database indexes on foreign keys
- Size of result sets
- Query complexity
For detailed performance analysis comparing IN subqueries with JOIN approaches, see filter/gormfilter/perf/perf_test.go.
relay provides seamless integration with gRPC/Protocol Buffers, including utilities for parsing proto enums, order fields, filters, and pagination requests.
For a complete example of proto definitions with pagination, ordering, and filtering support, see:
- Buf configuration:
protorelay/testdata/buf.yaml - Buf generation config:
protorelay/testdata/buf.gen.yaml - Proto definitions:
protorelay/testdata/proto/testdata/v1/product.proto - Relay pagination types:
protorelay/proto/relay/v1/relay.proto
For a complete implementation of a gRPC service using relay, refer to the ProductService.ListProducts method:
- Implementation:
protorelay/proto_test.go(ProductService.ListProducts)
This example demonstrates:
- Parsing proto order fields with
protorelay.ParseOrderBy - Parsing proto filters with
protofilter.ToMap - Creating a paginator with Base64-encoded cursors
- Converting between proto and internal types with
protorelay.ParsePagination - Building gRPC responses from pagination results
- FAQ: Computed Fields - Detailed guide on computed fields design and common questions
- GraphQL Connections - Relay-style pagination specification