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
69 lines
2.2 KiB
Go
69 lines
2.2 KiB
Go
package page
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/stephenafamo/bob"
|
|
"github.com/stephenafamo/bob/clause"
|
|
"github.com/stephenafamo/bob/expr"
|
|
"github.com/stephenafamo/scan"
|
|
)
|
|
|
|
// Mod applies validated keyset WHERE, ORDER BY and integer LIMIT size+1 to native
|
|
// PostgreSQL, SQLite or MySQL selects. Apply once to a query with no existing
|
|
// ORDER BY, LIMIT, OFFSET or FETCH; do not override pagination afterward.
|
|
// Callers may compose additional WHERE filters before or after this mod.
|
|
func Mod[Q interface {
|
|
AppendWhere(...any)
|
|
AppendOrder(bob.Expression)
|
|
SetLimit(any)
|
|
}, T any](p *Prepared[T]) bob.Mod[Q] {
|
|
return bob.ModFunc[Q](func(q Q) {
|
|
var alternatives []bob.Expression
|
|
for i, c := range p.sort.Columns {
|
|
desc := c.Direction == Desc
|
|
if p.direction == Backward {
|
|
desc = !desc
|
|
}
|
|
direction, operator := "ASC", ">"
|
|
if desc {
|
|
direction, operator = "DESC", "<"
|
|
}
|
|
q.AppendOrder(clause.OrderDef{Expression: c.Expression, Direction: direction})
|
|
if len(p.anchor) == 0 {
|
|
continue
|
|
}
|
|
terms := make([]bob.Expression, 0, i+1)
|
|
for j := 0; j < i; j++ {
|
|
terms = append(terms, expr.OP("=", p.sort.Columns[j].Expression, expr.Arg(p.anchor[j])))
|
|
}
|
|
terms = append(terms, expr.OP(operator, c.Expression, expr.Arg(p.anchor[i])))
|
|
alternatives = append(alternatives, grouped(terms, " AND "))
|
|
}
|
|
// authz: group the seek disjunction so caller filters constrain every branch.
|
|
if len(alternatives) > 0 {
|
|
q.AppendWhere(grouped(alternatives, " OR "))
|
|
}
|
|
q.SetLimit(p.size + 1)
|
|
})
|
|
}
|
|
|
|
func grouped(expressions []bob.Expression, separator string) bob.Expression {
|
|
return expr.Join{Exprs: []bob.Expression{
|
|
expr.Raw("("), expr.Join{Exprs: expressions, Sep: separator}, expr.Raw(")"),
|
|
}, Sep: expr.NoSep}
|
|
}
|
|
|
|
// Count executes a separate caller-built, authorized count query returning one
|
|
// int64 column. It never rewrites a paginated query or infers grouped/distinct
|
|
// semantics. Nothing runs unless called.
|
|
func Count(ctx context.Context, executor bob.Executor, query bob.Query) (int64, error) {
|
|
// authz: callers supply the same authorized relation as their item query.
|
|
count, err := bob.One(ctx, executor, query, scan.SingleColumnMapper[int64])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("page: count: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|