feat(page): add typed keyset pagination for Bob
Add versioned typed cursors, composable native query mods, bidirectional page assembly and opt-in authorized counts. Cover SQLite and PostgreSQL outcomes, strict cursor boundaries and a runnable example; require PostgreSQL service tests in CI. Refs: #6
This commit is contained in:
1 parent
da1ff94fe2
commit
8615d8ca66
11 files changed
+1142
-11
No files matched your search
@@ -0,0 +1,68 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user