Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for cursor direction #69

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
114 changes: 114 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A paginator doing cursor-based pagination based on [GORM](https://github.com/go-
- GORM `column` tag supported.
- Error handling enhancement.
- Exporting `cursor` module for advanced usage.
- Implement custom codec for cursor encoding/decoding.

## Installation

Expand Down Expand Up @@ -100,6 +101,34 @@ We first need to create a `paginator.Paginator` for `User`, here are some useful
return p
}
```

If using the directional cursor functionality provided by `ParseDirectionAndCursor` & `SerialiseDirectionAndCursor`, then one can pass the directional cursor to the `SetCursor` function instead:

```go
func CreateUserPaginator(
directionalCursor string,
order *paginator.Order,
limit *int,
) *paginator.Paginator {
p := paginator.New(
&paginator.Config{
Keys: []string{"ID", "JoinedAt"},
Limit: 10,
Order: paginator.ASC,
},
)
if order != nil {
p.SetOrder(*order)
}
if limit != nil {
p.SetLimit(*limit)
}
if directionalCursor != "" {
p.SetCursor(directionalCursor)
}
return p
}
```

3. Configure by `paginator.Rule` for fine grained setting for each key:

Expand Down Expand Up @@ -131,6 +160,91 @@ We first need to create a `paginator.Paginator` for `User`, here are some useful
}
```

4. By default the library encodes cursors with `base64`. If a custom encoding/decoding implementation is required, this can be implemented and passed as part of the configuration:


First implement your custom codec such that it conforms to the `CursorCodec` interface:


```go
type CursorCodec interface {
// Encode encodes model fields into cursor
Encode(
fields []pc.EncoderField,
model interface{},
) (string, error)

// Decode decodes cursor into model fields
Decode(
fields []pc.DecoderField,
cursor string,
model interface{},
) ([]interface{}, error)

// ParseDirectionAndCursor parses the direction and plain cursor. The resulting cursor can be fed to Decode()
ParseDirectionAndCursor(
cursor string,
) (direction, plainCursor string, err error)

// SerialiseDirectionAndCursor takes a direction and the plain cursor result from Encode()
// and serialises it into a directional cursor
SerialiseDirectionAndCursor(
direction string,
plainCursor string,
) (cursor string, err error)
}

type customCodec struct {}

func (*CustomCodec) Encode(fields []pc.EncoderField, model interface{}) (string, error) {
...
}

func (*CustomCodec) Decode(fields []pc.DecoderField, cursor string, model interface{}) ([]interface{}, error) {
...
}

func (*CustomCodec) ParseDirectionAndCursor(fields []pc.EncoderField, model interface{}) (string, error) {
...
}

func (*CustomCodec) SerialiseDirectionAndCursor(fields []pc.DecoderField, cursor string, model interface{}) ([]interface{}, error) {
...
}
```

Then pass an instance of your codec during initialisation:

```go
func CreateUserPaginator(/* ... */) {
codec := &customCodec{}

p := paginator.New(
&paginator.Config{
Rules: []paginator.Rule{
{
Key: "ID",
},
{
Key: "JoinedAt",
Order: paginator.DESC,
SQLRepr: "users.created_at",
NULLReplacement: "1970-01-01",
},
},
Limit: 10,
// supply a custom implementation for the encoder/decoder
CursorCodec: codec,
// Order here will apply to keys without order specified.
// In this example paginator will order by "ID" ASC, "JoinedAt" DESC.
Order: paginator.ASC,
},
)
// ...
return p
}
```

After knowing how to setup the paginator, we can start paginating `User` with GORM:

```go
Expand Down
3 changes: 3 additions & 0 deletions cursor/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ type Cursor struct {
After *string `json:"after" query:"after"`
Before *string `json:"before" query:"before"`
}

var BeforePrefix = []byte("<")
var AfterPrefix = []byte(">")
22 changes: 22 additions & 0 deletions cursor/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ func (d *Decoder) Decode(cursor string, model interface{}) (fields []interface{}
return
}

// ParseDirectionAndCursor parses the direction and plain cursor string. The cursor string will then be fed to Decode()
func (d *Decoder) ParseDirectionAndCursor(cursor string) (direction, plainCursor string, err error) {
b, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return "", "", ErrInvalidCursor
}

var cursorBytes []byte

if bytes.HasPrefix(b, BeforePrefix) {
direction = "before"
cursorBytes = bytes.TrimPrefix(b, BeforePrefix)
} else if bytes.HasPrefix(b, AfterPrefix) {
direction = "after"
cursorBytes = bytes.TrimPrefix(b, AfterPrefix)
}

plainCursor = base64.StdEncoding.EncodeToString(cursorBytes)

return
}

// DecodeStruct decodes cursor into model, model must be a pointer to struct or it will panic.
func (d *Decoder) DecodeStruct(cursor string, model interface{}) (err error) {
fields, err := d.Decode(cursor, model)
Expand Down
16 changes: 16 additions & 0 deletions cursor/decoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,19 @@ func (s *decoderSuite) TestDecodeStructInvalidCursor() {
err := NewDecoder([]DecoderField{{Key: "Value"}}).DecodeStruct("123", struct{ Value string }{})
s.Equal(ErrInvalidCursor, err)
}

func (s *decoderSuite) TestParseDirectionAndCursor() {
e := NewEncoder([]EncoderField{{Key: "Slice"}})
c, err := e.Encode(struct{ Slice []string }{Slice: []string{"value"}})
s.Nil(err)

c, err = e.SerialiseDirectionAndCursor("after", c)
s.Nil(err)

dec := NewDecoder([]DecoderField{{Key: "Slice"}})
direction, plainCursor, err := dec.ParseDirectionAndCursor(c)

s.Nil(err)
s.Equal(direction, "after")
s.NotEmpty(plainCursor)
}
25 changes: 25 additions & 0 deletions cursor/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"reflect"
"strings"

"github.com/pilagod/gorm-cursor-paginator/v2/internal/util"
)
Expand Down Expand Up @@ -34,6 +35,30 @@ func (e *Encoder) Encode(model interface{}) (string, error) {
return base64.StdEncoding.EncodeToString(b), nil
}

// SerialiseDirectionAndCursor serialises the direction and plain cursor string.
// This should be called with the result of Encode()
func (e *Encoder) SerialiseDirectionAndCursor(direction, plainCursor string) (string, error) {
b, err := base64.StdEncoding.DecodeString(plainCursor)
if err != nil {
return "", ErrInvalidCursor
}

var directionPrefix []byte

switch strings.ToLower(direction) {
case "after":
directionPrefix = AfterPrefix
case "before":
directionPrefix = BeforePrefix
default:
return "", ErrInvalidDirection
}

cursorBytes := append(directionPrefix, b...)

return base64.StdEncoding.EncodeToString(cursorBytes), nil
}

func (e *Encoder) marshalJSON(model interface{}) ([]byte, error) {
rv := util.ReflectValue(model)
fields := make([]interface{}, len(e.fields))
Expand Down
9 changes: 9 additions & 0 deletions cursor/encoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,12 @@ func (s *encoderSuite) TestSliceValue() {
_, err := e.Encode(struct{ Slice []string }{Slice: []string{"value"}})
s.Nil(err)
}

func (s *encoderSuite) TestSerialiseDirectionAndCursor() {
e := NewEncoder([]EncoderField{{Key: "Slice"}})
c, err := e.Encode(struct{ Slice []string }{Slice: []string{"value"}})
s.Nil(err)

_, err = e.SerialiseDirectionAndCursor("after", c)
s.Nil(err)
}
5 changes: 3 additions & 2 deletions cursor/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "errors"

// Errors for encoder
var (
ErrInvalidCursor = errors.New("invalid cursor")
ErrInvalidModel = errors.New("invalid model")
ErrInvalidCursor = errors.New("invalid cursor")
ErrInvalidModel = errors.New("invalid model")
ErrInvalidDirection = errors.New("invalid direction")
)
62 changes: 60 additions & 2 deletions paginator/cursor.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,64 @@
package paginator

import "github.com/pilagod/gorm-cursor-paginator/v2/cursor"
import (
pc "github.com/pilagod/gorm-cursor-paginator/v2/cursor"
)

// Cursor re-exports cursor.Cursor
type Cursor = cursor.Cursor
type Cursor = pc.Cursor

// CursorCodec encodes/decodes cursor
type CursorCodec interface {
// Encode encodes model fields into cursor
Encode(
fields []pc.EncoderField,
model interface{},
) (string, error)

// Decode decodes cursor into model fields
Decode(
fields []pc.DecoderField,
cursor string,
model interface{},
) ([]interface{}, error)

// ParseDirectionAndCursor parses the direction and plain cursor. The resulting cursor can be fed to Decode()
ParseDirectionAndCursor(
cursor string,
) (direction, plainCursor string, err error)

// SerialiseDirectionAndCursor takes a direction and the plain cursor result from Encode()
// and serialises it into a directional cursor
SerialiseDirectionAndCursor(
direction string,
plainCursor string,
) (cursor string, err error)
}

// JSONCursorCodec encodes/decodes cursor in JSON format
type JSONCursorCodec struct{}

// Encode encodes model fields into JSON format cursor
func (*JSONCursorCodec) Encode(
fields []pc.EncoderField,
model interface{},
) (string, error) {
return pc.NewEncoder(fields).Encode(model)
}

// Decode decodes JSON format cursor into model fields
func (*JSONCursorCodec) Decode(
fields []pc.DecoderField,
cursor string,
model interface{},
) ([]interface{}, error) {
return pc.NewDecoder(fields).Decode(cursor, model)
}

func (*JSONCursorCodec) ParseDirectionAndCursor(cursor string) (direction, plainCursor string, err error) {
return pc.NewDecoder(nil).ParseDirectionAndCursor(cursor)
}

func (*JSONCursorCodec) SerialiseDirectionAndCursor(direction, plainCursor string) (string, error) {
return pc.NewEncoder(nil).SerialiseDirectionAndCursor(direction, plainCursor)
}
12 changes: 12 additions & 0 deletions paginator/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var defaultConfig = Config{
Limit: 10,
Order: DESC,
AllowTupleCmp: FALSE,
CursorCodec: &JSONCursorCodec{},
}

// Option for paginator
Expand All @@ -28,6 +29,7 @@ type Config struct {
After string
Before string
AllowTupleCmp Flag
CursorCodec CursorCodec
}

// Apply applies config to paginator
Expand All @@ -54,6 +56,9 @@ func (c *Config) Apply(p *Paginator) {
if c.AllowTupleCmp != "" {
p.SetAllowTupleCmp(c.AllowTupleCmp == TRUE)
}
if c.CursorCodec != nil {
p.SetCursorCodec(c.CursorCodec)
}
}

// WithRules configures rules for paginator
Expand Down Expand Up @@ -104,3 +109,10 @@ func WithAllowTupleCmp(flag Flag) Option {
AllowTupleCmp: flag,
}
}

// WithCursorCodec configures custom cursor codec
func WithCursorCodec(codec CursorCodec) Option {
return &Config{
CursorCodec: codec,
}
}
Loading