feat(page): add typed keyset pagination for Bob (#15)
Add transport-neutral keyset pagination through native Bob query mods. Preserve supported cursor value types in a compact versioned format, with bidirectional pages, explicit row keys, and a separate opt-in count. Include SQLite tests, PostgreSQL service CI, and the cursor-format ADR. Closes #6
This commit was merged in pull request #15.
This commit is contained in:
1 parent
e5298cff37
commit
c45e8abe08
11 files changed
+1225
-1
No files matched your search
@@ -0,0 +1,220 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"uuid"
|
||||
|
||||
"github.com/stephenafamo/bob"
|
||||
"github.com/stephenafamo/bob/dialect/psql"
|
||||
pd "github.com/stephenafamo/bob/dialect/psql/dialect"
|
||||
"github.com/stephenafamo/bob/expr"
|
||||
)
|
||||
|
||||
func scalarSort() Sort {
|
||||
return Sort{ID: "scalar/v1", Columns: []Column{{Expression: expr.Quote("id"), Unique: true}}}
|
||||
}
|
||||
|
||||
func TestSupportedValues(t *testing.T) {
|
||||
instant := time.Date(2026, 10, 27, 12, 34, 56, 123456789, time.FixedZone("odd", 3661))
|
||||
values := []any{
|
||||
int(0), int(math.MaxInt), int(math.MinInt), int8(math.MinInt8), int8(math.MaxInt8),
|
||||
int16(math.MinInt16), int16(math.MaxInt16), int32(math.MinInt32), int32(math.MaxInt32),
|
||||
int64(math.MinInt64), int64(math.MaxInt64), int64(9007199254740993), "", "こんにちは\x00' OR 1=1 --",
|
||||
instant, time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC),
|
||||
uuid.MustParse("01234567-89ab-cdef-0123-456789abcdef"), uuid.Nil(),
|
||||
}
|
||||
for _, value := range values {
|
||||
t.Run(reflect.TypeOf(value).String(), func(t *testing.T) {
|
||||
token, err := encodeCursor("scalar/v1", []any{value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := decodeCursor(token, scalarSort())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := value
|
||||
if v, ok := value.(time.Time); ok {
|
||||
expected = v.UTC()
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, []any{expected}) {
|
||||
t.Fatalf("type/value: got %#v want %#v", decoded, expected)
|
||||
}
|
||||
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{Cursor: token}, Limits{Default: 1, Maximum: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := psql.Select(Mod[*pd.SelectQuery](p))
|
||||
_, args, err := bob.Build(t.Context(), q)
|
||||
if err != nil || !reflect.DeepEqual(args, decoded) {
|
||||
t.Fatalf("bound values: %#v %v", args, err)
|
||||
}
|
||||
if tm, ok := expected.(time.Time); ok {
|
||||
data, _ := base64.RawURLEncoding.DecodeString(token)
|
||||
if !strings.Contains(string(data), tm.Format(time.RFC3339Nano)) {
|
||||
t.Fatalf("non-UTC timestamp: %s", data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedValues(t *testing.T) {
|
||||
type named int64
|
||||
n := 1
|
||||
values := []any{nil, named(1), uint(1), uint64(1), float64(1), true, &n, []byte("x"), struct{}{}, "\xff", time.Date(-1, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
for _, value := range values {
|
||||
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := p.Page([]any{value})
|
||||
var valueError *ValueError
|
||||
if !errors.As(err, &valueError) || valueError.Position != 0 || !reflect.DeepEqual(result, Page[any]{}) {
|
||||
t.Fatalf("%T: %+v %v", value, result, err)
|
||||
}
|
||||
}
|
||||
type alias = int64
|
||||
if _, err := encodeCursor("scalar/v1", []any{alias(1)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedCursors(t *testing.T) {
|
||||
valid := `{"v":1,"sort":"scalar/v1","values":[["int64","1"]]}`
|
||||
cases := []struct{ data, kind string }{
|
||||
{`null`, "malformed"}, {`[]`, "malformed"}, {`{`, "malformed"},
|
||||
{valid + ` {}`, "malformed"},
|
||||
{strings.Replace(valid, `"v":1`, `"v":2`, 1), "version"},
|
||||
{strings.Replace(valid, `"v":1`, `"v":null`, 1), "malformed"},
|
||||
{strings.Replace(valid, `"v":1`, `"v":1,"v":1`, 1), "malformed"},
|
||||
{strings.Replace(valid, `"v":1,`, ``, 1), "malformed"},
|
||||
{strings.Replace(valid, `"v":1`, `"v":1,"columns":[]`, 1), "malformed"},
|
||||
{strings.Replace(valid, `scalar/v1`, `other/v1`, 1), "sort"},
|
||||
{strings.Replace(valid, `"scalar/v1"`, `null`, 1), "malformed"},
|
||||
{strings.Replace(valid, `[["int64","1"]]`, `null`, 1), "malformed"},
|
||||
{strings.Replace(valid, `[["int64","1"]]`, `[]`, 1), "malformed"},
|
||||
{strings.Replace(valid, `[["int64","1"]]`, `[["int64","1"],["int64","2"]]`, 1), "malformed"},
|
||||
}
|
||||
for _, entry := range []string{`null`, `{}`, `{"type":"int64","value":"1"}`, `[]`, `["int64"]`, `["int64","1","x"]`, `[null,"1"]`, `["int64",null]`, `["int64",1]`, `[1,"1"]`} {
|
||||
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, entry, 1), "malformed"})
|
||||
}
|
||||
for _, pair := range [][2]string{{"what", "1"}, {"int8", "128"}, {"int8", "-129"}, {"int16", "32768"}, {"int32", "2147483648"}, {"int64", "9223372036854775808"}, {"int", "999999999999999999999"}, {"int64", "01"}, {"int64", "+1"}, {"int64", "-0"}, {"int64", "1.0"}, {"time", "10000-01-01T00:00:00Z"}, {"time", "yesterday"}, {"time", "9999-12-31T23:59:59-01:00"}, {"time", "0000-01-01T00:00:00+01:00"}, {"uuid", "broken"}} {
|
||||
data, _ := json.Marshal(pair)
|
||||
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, string(data), 1), "value"})
|
||||
}
|
||||
for i, tc := range cases {
|
||||
_, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(tc.data)), scalarSort())
|
||||
var ce *CursorError
|
||||
if !errors.As(err, &ce) || ce.Kind != tc.kind {
|
||||
t.Fatalf("case %d %s: %v", i, tc.data, err)
|
||||
}
|
||||
}
|
||||
for _, token := range []string{"!", "====", base64.RawURLEncoding.EncodeToString([]byte(valid)) + "\n", base64.RawURLEncoding.EncodeToString([]byte("\xff"))} {
|
||||
if _, err := decodeCursor(token, scalarSort()); err == nil {
|
||||
t.Fatalf("accepted %q", token)
|
||||
}
|
||||
}
|
||||
// A well-formed edit is a new seek boundary, not an authentication failure.
|
||||
edited := strings.Replace(valid, `"1"`, `"9007199254740993"`, 1)
|
||||
got, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(edited)), scalarSort())
|
||||
if err != nil || got[0] != int64(9007199254740993) {
|
||||
t.Fatalf("edited boundary: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorSize(t *testing.T) {
|
||||
// Find the exact final-envelope boundary instead of assuming an overhead size.
|
||||
var at string
|
||||
var length int
|
||||
for length = 12000; length < 12400; length++ {
|
||||
token, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length)})
|
||||
if err == nil && len(token) == maxCursorBytes {
|
||||
at = token
|
||||
break
|
||||
}
|
||||
}
|
||||
if at == "" {
|
||||
t.Fatal("exact boundary not found")
|
||||
}
|
||||
if _, err := decodeCursor(at, scalarSort()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ce *CursorError
|
||||
if _, err := decodeCursor(at+"A", scalarSort()); !errors.As(err, &ce) || ce.Kind != "size" {
|
||||
t.Fatalf("received limit: %v", err)
|
||||
}
|
||||
p, err := Prepare(scalarSort(), func(v string) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := p.Page([]string{strings.Repeat("x", length+1)})
|
||||
if !errors.As(err, &ce) || ce.Kind != "size" || !reflect.DeepEqual(result, Page[string]{}) {
|
||||
t.Fatalf("emitted limit: %+v %v", result, err)
|
||||
}
|
||||
if _, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length-1)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Failure in an overflow row also invalidates the entire page.
|
||||
result, err = p.Page([]string{"valid", strings.Repeat("x", length+1)})
|
||||
if !errors.As(err, &ce) || !reflect.DeepEqual(result, Page[string]{}) {
|
||||
t.Fatalf("overflow: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAndKeyValidation(t *testing.T) {
|
||||
limits := Limits{Default: 2, Maximum: 3}
|
||||
badSorts := []Sort{{}, {ID: "x"}, {ID: "x", Columns: []Column{{Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id"), Direction: Order(2), Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id")}}}}
|
||||
key := func(v int) []any { return []any{v} }
|
||||
for _, sort := range badSorts {
|
||||
if _, err := Prepare(sort, key, Request{}, limits); err == nil {
|
||||
t.Fatalf("accepted sort: %+v", sort)
|
||||
}
|
||||
}
|
||||
if _, err := Prepare[int](scalarSort(), nil, Request{}, limits); err == nil {
|
||||
t.Fatal("nil key accepted")
|
||||
}
|
||||
for _, l := range []Limits{{}, {Default: 2, Maximum: 1}, {Default: 1, Maximum: math.MaxInt}} {
|
||||
if _, err := Prepare(scalarSort(), key, Request{}, l); err == nil {
|
||||
t.Fatalf("accepted limits %+v", l)
|
||||
}
|
||||
}
|
||||
for _, r := range []Request{{Size: -1}, {Direction: Direction(2)}} {
|
||||
if _, err := Prepare(scalarSort(), key, r, limits); err == nil {
|
||||
t.Fatal("bad request accepted")
|
||||
}
|
||||
}
|
||||
for _, size := range []int{0, 1, 3, 4, math.MaxInt} {
|
||||
p, err := Prepare(scalarSort(), key, Request{Size: size}, limits)
|
||||
want := min(size, 3)
|
||||
if size == 0 {
|
||||
want = 2
|
||||
}
|
||||
if err != nil || p.size != want {
|
||||
t.Fatalf("size %d: %+v %v", size, p, err)
|
||||
}
|
||||
}
|
||||
for _, badLength := range []int{0, 2} {
|
||||
p, err := Prepare(scalarSort(), func(v int) []any {
|
||||
if v == 9 {
|
||||
return make([]any, badLength)
|
||||
}
|
||||
return []any{v}
|
||||
}, Request{Size: 1}, limits)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := p.Page([]int{1, 9})
|
||||
var ke *KeyError
|
||||
if !errors.As(err, &ke) || ke.Expected != 1 || ke.Actual != badLength || !reflect.DeepEqual(result, Page[int]{}) {
|
||||
t.Fatalf("key: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user