Skip to content
Open
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
# The cursor project is a single dependency-free core module (stdlib only);
# all v0.2 features live here, so there are no nested sub-modules to verify.
- name: go mod tidy (verify clean)
run: |
go mod tidy
if [ -n "$(git status --porcelain go.mod go.sum)" ]; then
echo "go.mod/go.sum are not tidy; run 'go mod tidy' and commit:"
git --no-pager diff go.mod go.sum
exit 1
fi
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Seek-predicate builder** (`SeekKey`, `Column`, `Direction`, `Dialect`):
generate the row-wise `WHERE (a,b) > (?,?)`-style seek predicate and matching
`ORDER BY` from a typed composite key, with per-column `ASC`/`DESC`, forward and
backward paging, and dialect-aware placeholders for `database/sql` (`?`) and
`pgx`/`lib-pq` (`$N`). Golden tests assert exact SQL strings.
- **HMAC-signed cursors** (`SignedCodec`): sign cursor tokens with
HMAC-SHA256 so they cannot be forged or probed; tampered or wrong-key tokens are
rejected with `ErrTampered` on decode.
- **`net/http` query-param helper** (`ParseParams`, `LimitConfig`, `PageParams`):
parse `?cursor=&limit=` with default and max caps, clamping the limit safely
(including a usable zero-value config).
- **GraphQL Relay adapter** (`Connect`, `Connection`, `Edge`, `PageInfo`): build a
spec-compliant `pageInfo` (`startCursor`, `endCursor`, `hasNextPage`,
`hasPreviousPage`) and `edges` from a page result.
- **Runnable example**: a REST endpoint paginating a `(created_at, id)`-sorted
table via the seek builder against an in-memory fake data source, showing the
exact SQL the builder emits (`example_postgres_test.go`).

### Previously (v0.1)
- Initial v0.1 implementation: keyset (cursor) pagination with opaque cursor
encode/decode and a `Slice` batch-to-page helper. See [ROADMAP.md](ROADMAP.md)
for what comes next.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ Fetch one more row than you intend to return. If it comes back, there's another

Cursors are base64url-encoded JSON. They are opaque, not encrypted — don't put secrets in the sort key.

### v0.2 helpers

- **Seek-predicate builder** — generate the seek `WHERE` and matching `ORDER BY`
from a typed composite key, with per-column `ASC`/`DESC` and dialect-aware
placeholders (`?` for `database/sql`, `$N` for `pgx`):

```go
seek := cursor.NewSeekKey(
cursor.Column{Name: "created_at", Dir: cursor.Asc},
cursor.Column{Name: "id", Dir: cursor.Asc},
)
pred, nextArg := seek.Predicate(cursor.Dollar, false, 1)
// pred -> "(created_at > $1) OR (created_at = $2 AND id > $3)"
// order -> seek.OrderBy(false) == "created_at ASC, id ASC"
// args -> seek.Args(lastCreatedAt, lastID) // expands to ($1,$2,$3) values
```

- **HMAC-signed cursors** — sign tokens so they can't be forged or probed; a
tampered token decodes to `ErrTampered`:

```go
codec := cursor.NewSignedCodec(secretKey)
tok, _ := codec.Encode(k)
err := codec.Decode(tok, &k) // ErrTampered if altered
```

- **`net/http` param helper** — parse and clamp `?cursor=&limit=`:

```go
p := cursor.ParseParams(r, cursor.LimitConfig{Default: 20, Max: 100})
// p.Cursor, p.Limit (clamped to [1, Max])
```

- **GraphQL Relay adapter** — build spec-compliant `edges` + `pageInfo`:

```go
conn := cursor.Connect(items, func(u User) string { return enc(u) }, hasNext, hasPrev)
// conn.Edges, conn.PageInfo.{StartCursor,EndCursor,HasNextPage,HasPreviousPage}
```

See `example_postgres_test.go` for an end-to-end REST endpoint paginating a
`(created_at, id)`-sorted table with the seek builder.

## Install

```sh
Expand Down
164 changes: 164 additions & 0 deletions example_postgres_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cursor_test

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"time"

"github.com/hpower2/cursor"
)

// event is a row in our imaginary `events` table, sorted on (created_at, id).
type event struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Title string `json:"title"`
}

// seekPos is the cursor payload: the (created_at, id) of the last row on a page.
type seekPos struct {
CreatedAt time.Time `json:"c"`
ID int64 `json:"i"`
}

// fakeDB is an in-memory stand-in for Postgres. It holds rows already ordered by
// (created_at, id) and applies the seek predicate the same way the database would,
// so the example runs with no live DB while exercising the real builder output.
type fakeDB struct {
rows []event
}

// query mimics executing the SELECT the builder helps assemble. after is nil for
// the first page. It returns up to limit+1 rows so the caller can detect HasMore.
func (db *fakeDB) query(after *seekPos, limit int) []event {
var out []event
for _, r := range db.rows {
if after != nil {
// Forward seek on (created_at ASC, id ASC):
// (created_at > c) OR (created_at = c AND id > i)
past := r.CreatedAt.After(after.CreatedAt) ||
(r.CreatedAt.Equal(after.CreatedAt) && r.ID > after.ID)
if !past {
continue
}
}
out = append(out, r)
if len(out) == limit+1 {
break
}
}
return out
}

// Example_postgresREST shows a REST endpoint paginating an (created_at, id)-sorted
// table with the seek-predicate builder, and prints the exact SQL the builder
// emits for the pgx dialect.
func Example_postgresREST() {
// The seek key the endpoint paginates on, most-significant column first.
seek := cursor.NewSeekKey(
cursor.Column{Name: "created_at", Dir: cursor.Asc},
cursor.Column{Name: "id", Dir: cursor.Asc},
)

// Show the exact SQL fragments the builder produces (pgx "$N" placeholders).
// startArg=1 leaves the LIMIT placeholder to follow the seek args.
pred, nextArg := seek.Predicate(cursor.Dollar, false, 1)
orderBy := seek.OrderBy(false)
fullSQL := fmt.Sprintf(
"SELECT id, created_at, title FROM events WHERE %s ORDER BY %s LIMIT %s",
pred, orderBy, cursor.Dollar.Placeholder(nextArg),
)
fmt.Println("first-page SQL:")
fmt.Println(" SELECT id, created_at, title FROM events ORDER BY", orderBy, "LIMIT $1")
fmt.Println("next-page SQL:")
fmt.Println(" ", fullSQL)

// Seed the fake data source (already ordered by created_at, id).
base := time.Date(2026, 6, 12, 9, 0, 0, 0, time.UTC)
db := &fakeDB{rows: []event{
{1, base.Add(0 * time.Minute), "signup"},
{2, base.Add(1 * time.Minute), "login"},
{3, base.Add(1 * time.Minute), "click"}, // same timestamp as id=2; tie broken by id
{4, base.Add(2 * time.Minute), "logout"},
{5, base.Add(3 * time.Minute), "purchase"},
}}

// The HTTP handler: parse ?cursor=&limit=, run the seek query, return a page.
handler := func(w http.ResponseWriter, r *http.Request) {
params := cursor.ParseParams(r, cursor.LimitConfig{Default: 2, Max: 50})

var after *seekPos
if params.Cursor != "" {
var pos seekPos
if err := cursor.Decode(params.Cursor, &pos); err != nil {
http.Error(w, "bad cursor", http.StatusBadRequest)
return
}
after = &pos
}

batch := db.query(after, params.Limit)
page, err := cursor.Slice(batch, params.Limit, func(e event) (any, error) {
return seekPos{CreatedAt: e.CreatedAt, ID: e.ID}, nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// Stable output for the example: ids of the page plus the next cursor flag.
ids := make([]int64, len(page.Items))
for i, e := range page.Items {
ids[i] = e.ID
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
_ = json.NewEncoder(w).Encode(map[string]any{
"ids": ids,
"has_more": page.HasMore,
"next": page.Next,
})
}

srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()

// Page 1.
resp1 := fetch(srv.URL + "/events?limit=2")
fmt.Printf("page1: ids=%v has_more=%v\n", resp1["ids"], resp1["has_more"])

// Page 2, using the cursor returned by page 1.
next := resp1["next"].(string)
resp2 := fetch(srv.URL + "/events?limit=2&cursor=" + next)
fmt.Printf("page2: ids=%v has_more=%v\n", resp2["ids"], resp2["has_more"])

// Page 3 (final).
next2 := resp2["next"].(string)
resp3 := fetch(srv.URL + "/events?limit=2&cursor=" + next2)
fmt.Printf("page3: ids=%v has_more=%v\n", resp3["ids"], resp3["has_more"])

// Output:
// first-page SQL:
// SELECT id, created_at, title FROM events ORDER BY created_at ASC, id ASC LIMIT $1
// next-page SQL:
// SELECT id, created_at, title FROM events WHERE (created_at > $1) OR (created_at = $2 AND id > $3) ORDER BY created_at ASC, id ASC LIMIT $4
// page1: ids=[1 2] has_more=true
// page2: ids=[3 4] has_more=true
// page3: ids=[5] has_more=false
}

// fetch is a tiny helper that GETs a URL and decodes the JSON body.
func fetch(url string) map[string]any {
resp, err := http.Get(url)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
return out
}
52 changes: 52 additions & 0 deletions graphql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cursor

// PageInfo is the Relay-style connection metadata describing where a page sits
// within the full result set. Field names and JSON tags follow the GraphQL Cursor
// Connections specification so the struct serializes directly into a `pageInfo`.
type PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor string `json:"startCursor"`
EndCursor string `json:"endCursor"`
}

// Edge wraps a node with its cursor, as required by a Relay connection's `edges`.
type Edge[T any] struct {
Cursor string `json:"cursor"`
Node T `json:"node"`
}

// Connection is a Relay-style connection: a list of edges plus page metadata.
type Connection[T any] struct {
Edges []Edge[T] `json:"edges"`
PageInfo PageInfo `json:"pageInfo"`
}

// Connect builds a Relay Connection from a slice of items, a per-item cursor
// function, and flags describing neighbouring pages.
//
// - cursorOf returns the opaque cursor for an item (e.g. the result of
// SignedCodec.Encode or cursor.Encode over the item's sort key).
// - hasNext / hasPrev describe whether pages exist after / before this one;
// callers typically derive hasNext from a fetched limit+1 sentinel and
// hasPrev from whether an incoming cursor was supplied.
//
// startCursor and endCursor are taken from the first and last edges; for an empty
// page both are "" and both hasNextPage/hasPreviousPage are reported as given.
func Connect[T any](items []T, cursorOf func(T) string, hasNext, hasPrev bool) Connection[T] {
edges := make([]Edge[T], len(items))
for i, it := range items {
edges[i] = Edge[T]{Cursor: cursorOf(it), Node: it}
}

info := PageInfo{
HasNextPage: hasNext,
HasPreviousPage: hasPrev,
}
if len(edges) > 0 {
info.StartCursor = edges[0].Cursor
info.EndCursor = edges[len(edges)-1].Cursor
}

return Connection[T]{Edges: edges, PageInfo: info}
}
Loading
Loading