Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
193 lines
4.5 KiB
Go
193 lines
4.5 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package keysetpagination
|
|
|
|
import (
|
|
"cmp"
|
|
"reflect"
|
|
|
|
"github.com/jmoiron/sqlx/reflectx"
|
|
|
|
"github.com/ory/herodot"
|
|
)
|
|
|
|
func ErrInvalidPaginationToken() *herodot.DefaultError {
|
|
return herodot.ErrBadRequest().WithError("invalid pagination token")
|
|
}
|
|
|
|
type (
|
|
Paginator struct {
|
|
token, defaultToken PageToken
|
|
size, defaultSize, maxSize int
|
|
isLast bool
|
|
}
|
|
Option func(*Paginator)
|
|
)
|
|
|
|
const (
|
|
DefaultSize = 100
|
|
DefaultMaxSize = 500
|
|
)
|
|
|
|
var dbStructTagMapper = reflectx.NewMapper("db")
|
|
|
|
func (p *Paginator) DefaultToken() PageToken { return p.defaultToken }
|
|
func (p *Paginator) IsLast() bool { return p.isLast }
|
|
|
|
func (p *Paginator) PageToken() PageToken {
|
|
if p.token.cols != nil {
|
|
return p.token
|
|
}
|
|
return p.defaultToken
|
|
}
|
|
|
|
func (p *Paginator) Size() int {
|
|
defaultSize := cmp.Or(p.defaultSize, DefaultSize)
|
|
maxSize := cmp.Or(p.maxSize, DefaultMaxSize)
|
|
|
|
size := p.size
|
|
if size <= 0 {
|
|
size = defaultSize
|
|
}
|
|
if size > maxSize {
|
|
size = maxSize
|
|
}
|
|
|
|
return size
|
|
}
|
|
|
|
func (p *Paginator) ToOptions() []Option {
|
|
opts := make([]Option, 0, 6)
|
|
if p.token.cols != nil {
|
|
opts = append(opts, WithToken(p.token))
|
|
}
|
|
if p.defaultToken.cols != nil {
|
|
opts = append(opts, WithDefaultToken(p.defaultToken))
|
|
}
|
|
if p.size > 0 {
|
|
opts = append(opts, WithSize(p.size))
|
|
}
|
|
if p.defaultSize != DefaultSize {
|
|
opts = append(opts, WithDefaultSize(p.defaultSize))
|
|
}
|
|
if p.maxSize != DefaultMaxSize {
|
|
opts = append(opts, WithMaxSize(p.maxSize))
|
|
}
|
|
if p.isLast {
|
|
opts = append(opts, withIsLast(p.isLast))
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// Result removes the last item (if applicable) and returns the paginator for the next page.
|
|
func Result[I any](items []I, p *Paginator) ([]I, *Paginator) {
|
|
return ResultFunc(items, p, func(last I, colName string) any {
|
|
lastItemVal := reflect.ValueOf(last)
|
|
return dbStructTagMapper.FieldByName(lastItemVal, colName).Interface()
|
|
})
|
|
}
|
|
|
|
// ResultFunc removes the last item (if applicable) and returns the paginator for the next page.
|
|
// The extractor function is used to extract the column values from the last item.
|
|
func ResultFunc[I any](items []I, p *Paginator, extractor func(last I, colName string) any) ([]I, *Paginator) {
|
|
if len(items) <= p.Size() {
|
|
return items, &Paginator{
|
|
isLast: true,
|
|
|
|
defaultToken: p.defaultToken,
|
|
size: p.size,
|
|
defaultSize: p.defaultSize,
|
|
maxSize: p.maxSize,
|
|
}
|
|
}
|
|
|
|
items = items[:p.Size()]
|
|
lastItem := items[len(items)-1]
|
|
|
|
currentCols := p.PageToken().Columns()
|
|
newCols := make([]Column, len(currentCols))
|
|
for i, col := range currentCols {
|
|
newCols[i] = Column{
|
|
Name: col.Name,
|
|
Order: col.Order,
|
|
Nullable: col.Nullable,
|
|
HasConstraint: col.HasConstraint,
|
|
Value: extractor(lastItem, col.Name),
|
|
}
|
|
}
|
|
|
|
return items, &Paginator{
|
|
token: NewPageToken(newCols...),
|
|
defaultToken: p.defaultToken,
|
|
size: p.size,
|
|
defaultSize: p.defaultSize,
|
|
maxSize: p.maxSize,
|
|
}
|
|
}
|
|
|
|
func WithSize(size int) Option {
|
|
return func(p *Paginator) { p.size = size }
|
|
}
|
|
|
|
func WithDefaultSize(size int) Option {
|
|
return func(p *Paginator) { p.defaultSize = size }
|
|
}
|
|
|
|
func WithMaxSize(size int) Option {
|
|
return func(p *Paginator) { p.maxSize = size }
|
|
}
|
|
|
|
func WithToken(t PageToken) Option {
|
|
return func(p *Paginator) { p.token = t }
|
|
}
|
|
|
|
func WithDefaultToken(t PageToken) Option {
|
|
return func(p *Paginator) { p.defaultToken = t }
|
|
}
|
|
|
|
func withIsLast(isLast bool) Option {
|
|
return func(p *Paginator) { p.isLast = isLast }
|
|
}
|
|
|
|
func NewPaginator(modifiers ...Option) (*Paginator, error) {
|
|
p := &Paginator{
|
|
// these can still be overridden by the modifiers, but they should never be unset
|
|
maxSize: DefaultMaxSize,
|
|
defaultSize: DefaultSize,
|
|
}
|
|
for _, f := range modifiers {
|
|
f(p)
|
|
}
|
|
if err := p.validatePageToken(); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// validatePageToken ensures the page token columns strictly match DefaultToken's schema in order.
|
|
func (p *Paginator) validatePageToken() error {
|
|
tokenCols := p.PageToken().Columns()
|
|
if len(tokenCols) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if columnsMatch(tokenCols, p.defaultToken.Columns()) {
|
|
return nil
|
|
}
|
|
return ErrInvalidPaginationToken()
|
|
}
|
|
|
|
func columnsMatch(tokenCols, templateCols []Column) bool {
|
|
if len(templateCols) == 0 || len(tokenCols) != len(templateCols) {
|
|
return false
|
|
}
|
|
for i, col := range tokenCols {
|
|
def := templateCols[i]
|
|
if def.Name != col.Name || def.Order != col.Order || def.Nullable != col.Nullable {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|