3.2 KiB
Relay Cursor Pagination
This project implements Relay-style cursor pagination for all list fields across GraphQL APIs.
Schema types
Every paginated field uses the same set of types:
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
enum OrderDirection {
ASC
DESC
}
Each entity defines its own order field enum, order input, connection, and edge:
enum VendorOrderField {
CREATED_AT
NAME
}
input VendorOrder {
direction: OrderDirection!
field: VendorOrderField!
}
type VendorConnection {
totalCount: Int!
edges: [VendorEdge!]!
pageInfo: PageInfo!
}
type VendorEdge {
cursor: CursorKey!
node: Vendor!
}
Field arguments
Connection fields on parent types always use the standard Relay arguments:
type Organization {
vendors(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: VendorOrder
filter: VendorFilter
): VendorConnection!
}
first/after— forward pagination (returnsHeadposition)last/before— backward pagination (returnsTailposition)orderBy— optional, defaults toCREATED_AT/DESCfilter— optional, entity-specific filtering
Cursor format
Cursors are opaque CursorKey scalars. Internally they encode as base64url(JSON):
["<entity_global_id>", <sort_field_value>]
For example, a cursor sorting by created_at encodes the entity ID and its created_at timestamp. This enables keyset pagination — the database uses the cursor values to seek directly to the right position instead of using OFFSET.
Keyset pagination
The database query uses the cursor to build a WHERE clause that skips to the correct position:
- For
DESCordering: rows where(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id) - For
ASCordering: rows where(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)
The query fetches size + 1 (or size + 2 when a cursor is provided) rows to detect whether more pages exist in either direction. NewPage trims the extra rows and sets hasNextPage / hasPreviousPage accordingly.
For backward pagination (last / before), the SQL sort direction is reversed, and the result slice is reversed back to the correct order before building edges.
Default page size
When neither first nor last is provided, the default page size is 25.
Adding a new paginated field — checklist
- Schema — add
enum XxxOrderField,input XxxOrder,type XxxConnection,type XxxEdge, and the connection field with Relay arguments on the parent type - Coredata — add
*_order_field.go(withColumn(),IsValid(), marshaling),CursorKey(field)method on the entity, and theLoadAllBy*query using cursor SQL fragments +page.NewPage() - API types — add
*_connection.gowithOrderByalias, connection struct,NewXxxConnection,NewXxxEdge - Resolver — implement the resolver (authorize, build order, build cursor, call service, build connection)
- Codegen — run
go generatefor the relevant API package