Last active
May 9, 2022 09:30
-
-
Save hellozimi/2888e01c543c3285cdcb9efc4a0dc6ba to your computer and use it in GitHub Desktop.
Cursor pagination in Go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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