Skip to content

Instantly share code, notes, and snippets.

@hellozimi
Last active May 9, 2022 09:30
Show Gist options
  • Save hellozimi/2888e01c543c3285cdcb9efc4a0dc6ba to your computer and use it in GitHub Desktop.
Save hellozimi/2888e01c543c3285cdcb9efc4a0dc6ba to your computer and use it in GitHub Desktop.
Cursor pagination in Go
package cursor
// Cursor is just a string for flexibility
type Cursor string
// NewCursor transforms v into json and base64 encodes the json
func NewCursor(v any) (*Cursor, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
encoded := base64.RawURLEncoding.EncodeToString(b)
c := Cursor(encoded)
return &c, nil
}
// String returns the string representation of the cursor. Will return an
// empty string if the *Cursor is nil.
func (c *Cursor) String() string {
if c == nil {
return ""
}
return string(*c)
}
// Parse base64 decodes the cursor and json.Unmarshales to stores the result
// value pointed to by v.
func (c *Cursor) Parse(v any) error {
if c == nil {
return nil
}
decoded, err := base64.RawURLEncoding.DecodeString(c.String())
if err != nil {
return err
}
return json.Unmarshal(decoded, v)
}
package main
import (
"testing"
"time"
)
type feedCursor struct {
CreatedAt time.Time `json:"c"`
ID string `json:"i"`
}
func TestNewCursor(t *testing.T) {
for _, test := range []struct {
name string
input any
expectError bool
expectCursor bool
expectedString string
}{
{
name: "struct cursor",
input: &feedCursor{
CreatedAt: time.Date(2022, 5, 1, 15, 0, 0, 0, time.UTC),
ID: "0baa5b8f-0d51-43f2-9fd4-48a678dc96b1",
},
expectError: false,
expectCursor: true,
expectedString: "eyJjIjoiMjAyMi0wNS0wMVQxNTowMDowMFoiLCJpIjoiMGJhYTViOGYtMGQ1MS00M2YyLTlmZDQtNDhhNjc4ZGM5NmIxIn0",
},
{
name: "string value",
input: "user-id",
expectError: false,
expectCursor: true,
expectedString: "InVzZXItaWQi",
},
{
name: "int value",
input: 15,
expectError: false,
expectCursor: true,
expectedString: "MTU",
},
{
name: "float value",
input: 42.443,
expectError: false,
expectCursor: true,
expectedString: "NDIuNDQz",
},
{
name: "invalid json object",
input: func() {},
expectError: true,
expectCursor: false,
expectedString: "",
},
} {
t.Run(test.name, func(t *testing.T) {
c, err := NewCursor(test.input)
if (err != nil) != test.expectError {
t.Fatalf("expected NewCursor error=%v, error was %v", test.expectError, err)
}
if (c != nil) != test.expectCursor {
t.Fatalf("expected NewCursor *Cursor=%v, cursor was %v", test.expectCursor, c)
}
if c.String() != test.expectedString {
t.Errorf("expected cursor to be %q but was %q", test.expectedString, c.String())
}
})
}
}
func TestParse(t *testing.T) {
t.Run("parse struct", func(t *testing.T) {
c := Cursor("eyJjIjoiMjAyMi0wNS0wMVQxNTowMDowMFoiLCJpIjoiMGJhYTViOGYtMGQ1MS00M2YyLTlmZDQtNDhhNjc4ZGM5NmIxIn0")
var fc feedCursor
if err := c.Parse(&fc); err != nil {
t.Fatalf("expected Parse error to be nil but was %v", err)
}
if expected := time.Date(2022, 5, 1, 15, 0, 0, 0, time.UTC); !fc.CreatedAt.Equal(expected) {
t.Errorf("expected feedCursor.CreatedAt to be %v but was %v", expected, fc.CreatedAt)
}
if expected := "0baa5b8f-0d51-43f2-9fd4-48a678dc96b1"; fc.ID != expected {
t.Errorf("expected feedCursor.ID to be %v but was %v", expected, fc.ID)
}
})
t.Run("parse string", func(t *testing.T) {
c := Cursor("InVzZXItaWQi")
var s string
if err := c.Parse(&s); err != nil {
t.Fatalf("expected Parse error to be nil but was %v", err)
}
if expected := "user-id"; s != expected {
t.Errorf("expected parsed string to be %q but was %q", expected, s)
}
})
t.Run("parse int", func(t *testing.T) {
c := Cursor("MTU")
var i int
if err := c.Parse(&i); err != nil {
t.Fatalf("expected Parse error to be nil but was %v", err)
}
if expected := 15; i != expected {
t.Errorf("expected parsed string to be %q but was %q", expected, i)
}
})
t.Run("parse float", func(t *testing.T) {
c := Cursor("NDIuNDQz")
var f float64
if err := c.Parse(&f); err != nil {
t.Fatalf("expected Parse error to be nil but was %v", err)
}
if expected := 42.443; f != expected {
t.Errorf("expected parsed string to be %v but was %v", expected, f)
}
})
t.Run("parse empty string", func(t *testing.T) {
c := Cursor("")
var v any
if err := c.Parse(&v); err == nil {
t.Fatalf("expected Parse error not to be nil but was")
}
})
t.Run("parse invalid cursor", func(t *testing.T) {
c := Cursor("hello world")
var v any
if err := c.Parse(&v); err == nil {
t.Fatalf("expected Parse error not to be nil but was")
}
})
}
package example
import (
"database/sql"
"log"
"time"
)
// Example table
//
// CREATE TABLE post (
// id UUID DEFAULT gen_random_uuid(),
// title text NOT NULL,
// body text NOT NULL,
// created_at timestamptz NOT NULL DEFAULT NOW(),
//
// PRIMARY KEY(id)
// );
type usersCursor struct {
CreatedAt time.Time `json:"c"`
ID string `json:"i"`
}
func example() error {
c := Cursor("eyJjIjoiMjAyMi0wNS0wNFQwNDowMDowMFoiLCJpIjoiNkYxNjBFMEItODg2Ny00NUI1LTgzNDctRjlGMjYzMDFGRTdGIn0")
var cursor usersCursor
c.Parse(&cursor)
db, err := sql.Open("postgres", "postgres://postgres:password@localhost:5432/postgres")
if err != nil {
return err
}
stmt, err := db.Prepare(`
SELECT id, title, body, created_at FROM post
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 10;
`)
if err != nil {
return err
}
rows, err := stmt.Query(cursor.CreatedAt, cursor.ID)
if err != nil {
return err
}
for rows.Next() {
var (
id string
title string
body string
createdAt time.Time
)
if err := rows.Scan(&id, &title, &body, &createdAt); err != nil {
log.Fatal(err)
}
rowCursor, err := NewCursor(&usersCursor{createdAt, id})
if err != nil {
log.Fatalf("error creating cursor: %v", err)
}
log.Printf("cursor for id %q is: %s", id, rowCursor.String())
}
return nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment