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
+216
@@ -0,0 +1,216 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"uuid"
|
||||
)
|
||||
|
||||
const maxCursorBytes = 16384
|
||||
|
||||
// CursorError identifies malformed, version, sort, value, or size cursor failures via
|
||||
// Kind. It intentionally omits cursor payloads from its message.
|
||||
type CursorError struct{ Kind string }
|
||||
|
||||
// Error implements error without including cursor contents.
|
||||
func (e *CursorError) Error() string { return "page: cursor " + e.Kind }
|
||||
|
||||
// ValueError identifies an unsupported concrete type or invalid supported value
|
||||
// at Position in the row key, without exposing the value itself.
|
||||
type ValueError struct {
|
||||
Position int
|
||||
Type string
|
||||
}
|
||||
|
||||
// Error implements error without including key values.
|
||||
func (e *ValueError) Error() string {
|
||||
return fmt.Sprintf("page: invalid key value at %d (%s)", e.Position, e.Type)
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Version int `json:"v"`
|
||||
Sort string `json:"sort"`
|
||||
Values [][2]string `json:"values"`
|
||||
}
|
||||
|
||||
func encodeCursor(sortID string, values []any) (string, error) {
|
||||
e := envelope{Version: 1, Sort: sortID, Values: make([][2]string, len(values))}
|
||||
for i, v := range values {
|
||||
pair, ok := encodeValue(v)
|
||||
if !ok {
|
||||
return "", &ValueError{Position: i, Type: fmt.Sprintf("%T", v)}
|
||||
}
|
||||
e.Values[i] = pair
|
||||
}
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return "", &CursorError{Kind: "malformed"}
|
||||
}
|
||||
if base64.RawURLEncoding.EncodedLen(len(data)) > maxCursorBytes {
|
||||
return "", &CursorError{Kind: "size"}
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func encodeValue(value any) ([2]string, bool) {
|
||||
var tag, payload string
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
tag, payload = "int", strconv.FormatInt(int64(v), 10)
|
||||
case int8:
|
||||
tag, payload = "int8", strconv.FormatInt(int64(v), 10)
|
||||
case int16:
|
||||
tag, payload = "int16", strconv.FormatInt(int64(v), 10)
|
||||
case int32:
|
||||
tag, payload = "int32", strconv.FormatInt(int64(v), 10)
|
||||
case int64:
|
||||
tag, payload = "int64", strconv.FormatInt(v, 10)
|
||||
case string:
|
||||
if !utf8.ValidString(v) {
|
||||
return [2]string{}, false
|
||||
}
|
||||
tag, payload = "string", v
|
||||
case time.Time:
|
||||
v = v.UTC()
|
||||
if v.Year() < 0 || v.Year() > 9999 {
|
||||
return [2]string{}, false
|
||||
}
|
||||
tag, payload = "time", v.Format(time.RFC3339Nano)
|
||||
case uuid.UUID:
|
||||
tag, payload = "uuid", v.String()
|
||||
default:
|
||||
return [2]string{}, false
|
||||
}
|
||||
return [2]string{tag, payload}, true
|
||||
}
|
||||
|
||||
func decodeCursor(token string, sort Sort) ([]any, error) {
|
||||
failure := func(kind string) ([]any, error) { return nil, &CursorError{Kind: kind} }
|
||||
if len(token) > maxCursorBytes {
|
||||
return failure("size")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.Strict().DecodeString(token)
|
||||
if err != nil || !utf8.Valid(data) || base64.RawURLEncoding.EncodeToString(data) != token {
|
||||
return failure("malformed")
|
||||
}
|
||||
// invariant: exactly three distinct envelope fields; struct decoding alone permits duplicates.
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
start, err := decoder.Token()
|
||||
if err != nil || start != json.Delim('{') {
|
||||
return failure("malformed")
|
||||
}
|
||||
fields := make(map[string]json.RawMessage, 3)
|
||||
for decoder.More() {
|
||||
name, err := decoder.Token()
|
||||
if err != nil {
|
||||
return failure("malformed")
|
||||
}
|
||||
key, ok := name.(string)
|
||||
if !ok || (key != "v" && key != "sort" && key != "values") {
|
||||
return failure("malformed")
|
||||
}
|
||||
if _, exists := fields[key]; exists {
|
||||
return failure("malformed")
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if decoder.Decode(&raw) != nil {
|
||||
return failure("malformed")
|
||||
}
|
||||
fields[key] = raw
|
||||
}
|
||||
if _, err := decoder.Token(); err != nil || len(fields) != 3 {
|
||||
return failure("malformed")
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
return failure("malformed")
|
||||
}
|
||||
var version int
|
||||
if bytes.Equal(fields["v"], []byte("null")) || json.Unmarshal(fields["v"], &version) != nil {
|
||||
return failure("malformed")
|
||||
}
|
||||
if version != 1 {
|
||||
return failure("version")
|
||||
}
|
||||
var id string
|
||||
if !jsonString(fields["sort"], &id) {
|
||||
return failure("malformed")
|
||||
}
|
||||
if id != sort.ID {
|
||||
return failure("sort")
|
||||
}
|
||||
var entries []json.RawMessage
|
||||
if len(fields["values"]) == 0 || fields["values"][0] != '[' || json.Unmarshal(fields["values"], &entries) != nil || len(entries) != len(sort.Columns) {
|
||||
return failure("malformed")
|
||||
}
|
||||
values := make([]any, len(entries))
|
||||
for i, entry := range entries {
|
||||
var pair []json.RawMessage
|
||||
if len(entry) == 0 || entry[0] != '[' || json.Unmarshal(entry, &pair) != nil || len(pair) != 2 {
|
||||
return failure("malformed")
|
||||
}
|
||||
var tag, payload string
|
||||
if !jsonString(pair[0], &tag) || !jsonString(pair[1], &payload) {
|
||||
return failure("malformed")
|
||||
}
|
||||
value, ok := decodeValue(tag, payload)
|
||||
if !ok {
|
||||
return failure("value")
|
||||
}
|
||||
values[i] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func jsonString(raw json.RawMessage, value *string) bool {
|
||||
return len(raw) > 0 && raw[0] == '"' && json.Unmarshal(raw, value) == nil && utf8.ValidString(*value)
|
||||
}
|
||||
|
||||
func decodeValue(tag, payload string) (any, bool) {
|
||||
switch tag {
|
||||
case "int", "int8", "int16", "int32", "int64":
|
||||
bits := strconv.IntSize
|
||||
switch tag {
|
||||
case "int8":
|
||||
bits = 8
|
||||
case "int16":
|
||||
bits = 16
|
||||
case "int32":
|
||||
bits = 32
|
||||
case "int64":
|
||||
bits = 64
|
||||
}
|
||||
n, err := strconv.ParseInt(payload, 10, bits)
|
||||
if err != nil || strconv.FormatInt(n, 10) != payload {
|
||||
return nil, false
|
||||
}
|
||||
switch tag {
|
||||
case "int":
|
||||
return int(n), true
|
||||
case "int8":
|
||||
return int8(n), true
|
||||
case "int16":
|
||||
return int16(n), true
|
||||
case "int32":
|
||||
return int32(n), true
|
||||
default:
|
||||
return n, true
|
||||
}
|
||||
case "string":
|
||||
return payload, utf8.ValidString(payload)
|
||||
case "time":
|
||||
t, err := time.Parse(time.RFC3339Nano, payload)
|
||||
t = t.UTC()
|
||||
return t, err == nil && t.Year() >= 0 && t.Year() <= 9999
|
||||
case "uuid":
|
||||
id, err := uuid.Parse(payload)
|
||||
return id, err == nil && id.String() == payload
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user