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:
end committed 2026-10-27 12:55:23 -07:00
1 parent da1ff94fe2
commit 8615d8ca66
11 files changed
+1142 -11

No files matched your search

+68
View File
@@ -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
}