From ef9a7a6b24bbd3b77183944c039b4eca2ad192e7 Mon Sep 17 00:00:00 2001 From: Sasha Hilton Date: Fri, 19 Jul 2024 17:21:38 +0100 Subject: [PATCH 1/4] feat: allow supplying custom encoder/decoder implementation --- README.md | 45 ++++++++++++++++++++++++++++++++++ paginator/option.go | 8 ++++++ paginator/paginator.go | 55 +++++++++++++++++++++++++++++------------- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index aebd42f..f2506be 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,51 @@ 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: + + ```go + func CreateUserPaginator(/* ... */) { + 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 + CursorCodecFactory: NewCustomCodec, + // 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 + } + ``` + +Where the `NewCustomCodec` parameter is a function with the following signature: + +```go +func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec +``` + +Returning an implementation conforming to the `CursorCodec` interface: + +```go +type CursorCodec interface { + Encode(model interface{}) (string, error) + Decode(cursor string, model interface{}) (fields []interface{}, err error) +} +``` + After knowing how to setup the paginator, we can start paginating `User` with GORM: ```go diff --git a/paginator/option.go b/paginator/option.go index 47c3443..39f8bb3 100644 --- a/paginator/option.go +++ b/paginator/option.go @@ -1,5 +1,7 @@ package paginator +import "github.com/pilagod/gorm-cursor-paginator/v2/cursor" + type Flag string const ( @@ -28,6 +30,8 @@ type Config struct { After string Before string AllowTupleCmp Flag + + CursorCodecFactory func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec } // Apply applies config to paginator @@ -54,6 +58,10 @@ func (c *Config) Apply(p *Paginator) { if c.AllowTupleCmp != "" { p.SetAllowTupleCmp(c.AllowTupleCmp == TRUE) } + + if c.CursorCodecFactory != nil { + p.SetCursorCodecFactory(c.CursorCodecFactory) + } } // WithRules configures rules for paginator diff --git a/paginator/paginator.go b/paginator/paginator.go index 2eef3c0..6238cf7 100644 --- a/paginator/paginator.go +++ b/paginator/paginator.go @@ -17,9 +17,34 @@ func New(opts ...Option) *Paginator { for _, opt := range append([]Option{&defaultConfig}, opts...) { opt.Apply(p) } + + // if codec provided we use that, otherwise we use the default (base64) cursor implementation + if p.codecFactory != nil { + codec := p.codecFactory(p.getEncoderFields(), p.getDecoderFields()) + + p.cursorEncoder = codec + p.cursorDecoder = codec + } else { + p.cursorEncoder = cursor.NewEncoder(p.getEncoderFields()) + p.cursorDecoder = cursor.NewDecoder(p.getDecoderFields()) + } + return p } +type CursorCodec interface { + Encode(model interface{}) (string, error) + Decode(cursor string, model interface{}) (fields []interface{}, err error) +} + +type cursorEncoder interface { + Encode(model interface{}) (string, error) +} + +type cursorDecoder interface { + Decode(cursor string, model interface{}) (fields []interface{}, err error) +} + // Paginator a builder doing pagination type Paginator struct { cursor Cursor @@ -27,6 +52,15 @@ type Paginator struct { limit int order Order allowTupleCmp bool + + cursorEncoder cursorEncoder + cursorDecoder cursorDecoder + + codecFactory func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec +} + +func (p *Paginator) SetCursorCodecFactory(codecFactory func([]cursor.EncoderField, []cursor.DecoderField) CursorCodec) { + p.codecFactory = codecFactory } // SetRules sets paging rules @@ -104,16 +138,6 @@ func (p *Paginator) Paginate(db *gorm.DB, dest interface{}) (result *gorm.DB, c return } -// GetCursorEncoder returns cursor encoder based on paginator rules -func (p *Paginator) GetCursorEncoder() *cursor.Encoder { - return cursor.NewEncoder(p.getEncoderFields()) -} - -// GetCursorDecoder returns cursor decoder based on paginator rules -func (p *Paginator) GetCursorDecoder() *cursor.Decoder { - return cursor.NewDecoder(p.getDecoderFields()) -} - /* private */ func (p *Paginator) validate(db *gorm.DB, dest interface{}) (err error) { @@ -189,14 +213,12 @@ func isNil(i interface{}) bool { } func (p *Paginator) decodeCursor(dest interface{}) (result []interface{}, err error) { - decoder := p.GetCursorDecoder() - if p.isForward() { - if result, err = decoder.Decode(*p.cursor.After, dest); err != nil { + if result, err = p.cursorDecoder.Decode(*p.cursor.After, dest); err != nil { err = ErrInvalidCursor } } else if p.isBackward() { - if result, err = decoder.Decode(*p.cursor.Before, dest); err != nil { + if result, err = p.cursorDecoder.Decode(*p.cursor.Before, dest); err != nil { err = ErrInvalidCursor } } @@ -312,10 +334,9 @@ func (p *Paginator) buildCursorSQLQueryArgs(fields []interface{}) (args []interf } func (p *Paginator) encodeCursor(elems reflect.Value, hasMore bool) (result Cursor, err error) { - encoder := p.GetCursorEncoder() // encode after cursor if p.isBackward() || hasMore { - c, err := encoder.Encode(elems.Index(elems.Len() - 1)) + c, err := p.cursorEncoder.Encode(elems.Index(elems.Len() - 1)) if err != nil { return Cursor{}, err } @@ -323,7 +344,7 @@ func (p *Paginator) encodeCursor(elems reflect.Value, hasMore bool) (result Curs } // encode before cursor if p.isForward() || (hasMore && p.isBackward()) { - c, err := encoder.Encode(elems.Index(0)) + c, err := p.cursorEncoder.Encode(elems.Index(0)) if err != nil { return Cursor{}, err } From ec7091d51ff367ba0e8de62eb331deaa7b652539 Mon Sep 17 00:00:00 2001 From: pilagod Date: Fri, 9 Aug 2024 15:37:46 +0800 Subject: [PATCH 2/4] refine cursor codec interface --- paginator/cursor.go | 42 ++++++++++++++++++++++++++++++++++-- paginator/option.go | 18 ++++++++++------ paginator/paginator.go | 48 +++++++++--------------------------------- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/paginator/cursor.go b/paginator/cursor.go index ebb99f3..7c69a1c 100644 --- a/paginator/cursor.go +++ b/paginator/cursor.go @@ -1,6 +1,44 @@ 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) +} + +// 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) +} diff --git a/paginator/option.go b/paginator/option.go index 39f8bb3..bba2a88 100644 --- a/paginator/option.go +++ b/paginator/option.go @@ -1,7 +1,5 @@ package paginator -import "github.com/pilagod/gorm-cursor-paginator/v2/cursor" - type Flag string const ( @@ -14,6 +12,7 @@ var defaultConfig = Config{ Limit: 10, Order: DESC, AllowTupleCmp: FALSE, + CursorCodec: &JSONCursorCodec{}, } // Option for paginator @@ -30,8 +29,7 @@ type Config struct { After string Before string AllowTupleCmp Flag - - CursorCodecFactory func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec + CursorCodec CursorCodec } // Apply applies config to paginator @@ -58,9 +56,8 @@ func (c *Config) Apply(p *Paginator) { if c.AllowTupleCmp != "" { p.SetAllowTupleCmp(c.AllowTupleCmp == TRUE) } - - if c.CursorCodecFactory != nil { - p.SetCursorCodecFactory(c.CursorCodecFactory) + if c.CursorCodec != nil { + p.SetCursorCodec(c.CursorCodec) } } @@ -112,3 +109,10 @@ func WithAllowTupleCmp(flag Flag) Option { AllowTupleCmp: flag, } } + +// WithCursorCodec configures custom cursor codec +func WithCursorCodec(codec CursorCodec) Option { + return &Config{ + CursorCodec: codec, + } +} diff --git a/paginator/paginator.go b/paginator/paginator.go index 6238cf7..87136f8 100644 --- a/paginator/paginator.go +++ b/paginator/paginator.go @@ -17,34 +17,9 @@ func New(opts ...Option) *Paginator { for _, opt := range append([]Option{&defaultConfig}, opts...) { opt.Apply(p) } - - // if codec provided we use that, otherwise we use the default (base64) cursor implementation - if p.codecFactory != nil { - codec := p.codecFactory(p.getEncoderFields(), p.getDecoderFields()) - - p.cursorEncoder = codec - p.cursorDecoder = codec - } else { - p.cursorEncoder = cursor.NewEncoder(p.getEncoderFields()) - p.cursorDecoder = cursor.NewDecoder(p.getDecoderFields()) - } - return p } -type CursorCodec interface { - Encode(model interface{}) (string, error) - Decode(cursor string, model interface{}) (fields []interface{}, err error) -} - -type cursorEncoder interface { - Encode(model interface{}) (string, error) -} - -type cursorDecoder interface { - Decode(cursor string, model interface{}) (fields []interface{}, err error) -} - // Paginator a builder doing pagination type Paginator struct { cursor Cursor @@ -52,15 +27,7 @@ type Paginator struct { limit int order Order allowTupleCmp bool - - cursorEncoder cursorEncoder - cursorDecoder cursorDecoder - - codecFactory func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec -} - -func (p *Paginator) SetCursorCodecFactory(codecFactory func([]cursor.EncoderField, []cursor.DecoderField) CursorCodec) { - p.codecFactory = codecFactory + cursorCodec CursorCodec } // SetRules sets paging rules @@ -105,6 +72,11 @@ func (p *Paginator) SetAllowTupleCmp(allow bool) { p.allowTupleCmp = allow } +// SetCursorCodec sets custom cursor codec +func (p *Paginator) SetCursorCodec(codec CursorCodec) { + p.cursorCodec = codec +} + // Paginate paginates data func (p *Paginator) Paginate(db *gorm.DB, dest interface{}) (result *gorm.DB, c Cursor, err error) { if err = p.validate(db, dest); err != nil { @@ -214,11 +186,11 @@ func isNil(i interface{}) bool { func (p *Paginator) decodeCursor(dest interface{}) (result []interface{}, err error) { if p.isForward() { - if result, err = p.cursorDecoder.Decode(*p.cursor.After, dest); err != nil { + if result, err = p.cursorCodec.Decode(p.getDecoderFields(), *p.cursor.After, dest); err != nil { err = ErrInvalidCursor } } else if p.isBackward() { - if result, err = p.cursorDecoder.Decode(*p.cursor.Before, dest); err != nil { + if result, err = p.cursorCodec.Decode(p.getDecoderFields(), *p.cursor.Before, dest); err != nil { err = ErrInvalidCursor } } @@ -336,7 +308,7 @@ func (p *Paginator) buildCursorSQLQueryArgs(fields []interface{}) (args []interf func (p *Paginator) encodeCursor(elems reflect.Value, hasMore bool) (result Cursor, err error) { // encode after cursor if p.isBackward() || hasMore { - c, err := p.cursorEncoder.Encode(elems.Index(elems.Len() - 1)) + c, err := p.cursorCodec.Encode(p.getEncoderFields(), elems.Index(elems.Len()-1)) if err != nil { return Cursor{}, err } @@ -344,7 +316,7 @@ func (p *Paginator) encodeCursor(elems reflect.Value, hasMore bool) (result Curs } // encode before cursor if p.isForward() || (hasMore && p.isBackward()) { - c, err := p.cursorEncoder.Encode(elems.Index(0)) + c, err := p.cursorCodec.Encode(p.getEncoderFields(), elems.Index(0)) if err != nil { return Cursor{}, err } From 354c14b5005c589d903f5a11f9f0651d70079865 Mon Sep 17 00:00:00 2001 From: pilagod Date: Fri, 9 Aug 2024 16:15:16 +0800 Subject: [PATCH 3/4] add custom cursor codec test --- paginator/paginator_paginate_test.go | 82 +++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/paginator/paginator_paginate_test.go b/paginator/paginator_paginate_test.go index 156a312..2305caa 100644 --- a/paginator/paginator_paginate_test.go +++ b/paginator/paginator_paginate_test.go @@ -1,11 +1,16 @@ package paginator import ( + "fmt" "reflect" + "strconv" "time" - "github.com/pilagod/pointer" "gorm.io/gorm" + + pc "github.com/pilagod/gorm-cursor-paginator/v2/cursor" + "github.com/pilagod/gorm-cursor-paginator/v2/internal/util" + "github.com/pilagod/pointer" ) func (s *paginatorSuite) TestPaginateDefaultOptions() { @@ -644,6 +649,8 @@ func (s *paginatorSuite) TestPaginateReplaceNULL() { s.assertForwardOnly(c) } +/* Custom Type */ + func (s *paginatorSuite) TestPaginateCustomTypeInt() { s.givenOrders(9) @@ -827,6 +834,79 @@ func (s *paginatorSuite) TestPaginateCustomTypeNullable() { s.assertIDs(p5, 1) } +/* Custom Cursor Codec */ + +type idCursorCodec struct{} + +func (c *idCursorCodec) Encode(fields []pc.EncoderField, model interface{}) (string, error) { + if len(fields) != 1 || fields[0].Key != "ID" { + return "", fmt.Errorf("ID field is required") + } + id := util.ReflectValue(model).FieldByName("ID").Interface() + return fmt.Sprintf("%d", id), nil +} + +func (c *idCursorCodec) Decode(fields []pc.DecoderField, cursor string, model interface{}) ([]interface{}, error) { + if len(fields) != 1 || fields[0].Key != "ID" { + return nil, fmt.Errorf("ID field is required") + } + if _, ok := util.ReflectType(model).FieldByName("ID"); !ok { + return nil, fmt.Errorf("ID field is required on model") + } + id, err := strconv.Atoi(cursor) + if err != nil { + return nil, err + } + return []interface{}{id}, nil +} + +func (s *paginatorSuite) TestPaginateCustomCodec() { + s.givenOrders([]order{ + { + ID: 1, + }, + { + ID: 2, + }, + { + ID: 3, + }, + }) + + cfg := Config{ + Limit: 2, + } + codec := &idCursorCodec{} + + var p1 []order + _, c, _ := New( + &cfg, + WithCursorCodec(codec), + ).Paginate(s.db, &p1) + s.Len(p1, 2) + s.assertForwardOnly(c) + s.assertIDs(p1, 3, 2) + + var p2 []order + _, c, _ = New( + &cfg, + WithCursorCodec(codec), + WithAfter(*c.After), + ).Paginate(s.db, &p2) + s.Len(p2, 1) + s.assertBackwardOnly(c) + s.assertIDs(p2, 1) + + var p3 []order + _, c, _ = New( + &cfg, + WithCursorCodec(codec), + WithBefore(*c.Before), + ).Paginate(s.db, &p3) + s.Len(p3, 2) + s.assertIDs(p3, 3, 2) +} + /* compatibility */ func (s *paginatorSuite) TestPaginateConsistencyBetweenBuilderAndKeyOptions() { From d76660618a449e6e0befd59e5877584f4423eca4 Mon Sep 17 00:00:00 2001 From: Sasha Hilton Date: Fri, 27 Sep 2024 17:48:08 +0100 Subject: [PATCH 4/4] docs: update docs to reflect usage of new Codec option --- README.md | 87 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index f2506be..b79398a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -133,46 +134,66 @@ 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: - ```go - func CreateUserPaginator(/* ... */) { - 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 - CursorCodecFactory: NewCustomCodec, - // 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 - } - ``` -Where the `NewCustomCodec` parameter is a function with the following signature: +First implement your custom codec such that it conforms to the `CursorCodec` interface: + ```go -func(encoderFields []cursor.EncoderField, decoderFields []cursor.DecoderField) CursorCodec +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) +} + +type customCodec struct {} + +func (cc *CustomCodec) Encode(fields []pc.EncoderField, model interface{}) (string, error) { + ... +} + +func (cc *CustomCodec) Decode(fields []pc.DecoderField, cursor string, model interface{}) ([]interface{}, error) { + ... +} ``` -Returning an implementation conforming to the `CursorCodec` interface: +Then pass an instance of your codec during initialisation: ```go -type CursorCodec interface { - Encode(model interface{}) (string, error) - Decode(cursor string, model interface{}) (fields []interface{}, err error) +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 } ```