Files
end 683b0ddbf4 docs: add package godoc and clarify public API
Prepare the extracted library for external consumption with grouped
godoc, extension point docs, and setup requirements. Wire
ErrBadCursorString into decode paths and drop redundant Paginate*Conds
aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:14:05 -07:00

208 lines
5.5 KiB
Go

package cursor
import (
"fmt"
"log/slog"
"gitea.auvem.com/go-toolkit/dbx"
"github.com/go-jet/jet/v2/mysql"
)
// QueryCountResult is a struct that contains the total number of rows that will
// be returned by a query, the number of rows after an end row, and the number of
// rows before a start row.
type QueryCountResult struct {
Total int
Before int
After int
}
// QueryCountFn is a function type that abstracts the counting of rows down to
// a single function that takes a Queryable interface and returns a QueryCountResult.
type QueryCountFn = func(
sqlo dbx.Queryable,
start GenericCursor,
end GenericCursor,
) (QueryCountResult, error)
// QueryCount counts the number of rows returned from a table with the given
// conditions. Returns a QueryCountResult or an error if anything goes wrong.
func QueryCount(
sqlo dbx.Queryable,
col mysql.Column,
tbl mysql.ReadableTable,
conds mysql.BoolExpression,
start GenericCursor,
end GenericCursor,
) (QueryCountResult, error) {
var comparator struct {
start string
end string
}
if start.Direction() == OrderAscending {
comparator.start = "<"
comparator.end = ">"
} else {
comparator.start = ">"
comparator.end = "<"
}
stmt := tbl.SELECT(
mysql.COUNT(col).AS("QueryCountResult.Total"),
mysql.COUNT(countBoundExpr(start, comparator.start)).AS("QueryCountResult.Before"),
mysql.COUNT(countBoundExpr(end, comparator.end)).AS("QueryCountResult.After"),
).WHERE(conds)
var res QueryCountResult
err := stmt.Query(sqlo, &res)
if err != nil {
return QueryCountResult{}, fmt.Errorf("failed to query count: %w", err)
}
return res, nil
}
func countBoundExpr(c GenericCursor, comparator string) mysql.Expression {
if c.IsComposite() {
return mysql.Raw(
fmt.Sprintf("IF(%s, 1, NULL)", compositeTupleSQL(comparator, c)),
compositeTupleArgs(c),
)
}
ck := c.GenericIndex().ColumnKey()
return mysql.Raw(
fmt.Sprintf("IF(%s.%s %s ?, 1, NULL)", ck.Table, ck.Column, comparator),
mysql.RawArgs{"?": c.GenericIndex()},
)
}
func compositeTupleSQL(comparator string, c GenericCursor) string {
orderCK := NewColumnKey(c.OrderCol())
indexCK := c.GenericIndex().ColumnKey()
return fmt.Sprintf(
"(%s.%s, %s.%s) %s (#order, #index)",
orderCK.Table, orderCK.Column,
indexCK.Table, indexCK.Column,
comparator,
)
}
func compositeTupleArgs(c GenericCursor) mysql.RawArgs {
return mysql.RawArgs{
"#order": c.GenericOrderValue(),
"#index": c.GenericIndex(),
}
}
// BuildQueryCountFn builds a QueryCountFn that can be used to count rows in a
// table with the given conditions.
func BuildQueryCountFn(
col mysql.Column,
tbl mysql.ReadableTable,
conds mysql.BoolExpression,
) QueryCountFn {
return func(sqlo dbx.Queryable, start GenericCursor, end GenericCursor) (QueryCountResult, error) {
return QueryCount(sqlo, col, tbl, conds, start, end)
}
}
// PaginateConds returns a mysql.BoolExpression that paginates results using the
// provided cursor as a base position. Nil or empty cursors match all rows.
func PaginateConds[IE mysql.Expression, IC mysql.Column](c *Cursor[IE, IC]) mysql.BoolExpression {
return paginateFromGeneric(c)
}
func paginateFromGeneric(c GenericCursor) mysql.BoolExpression {
if c == nil || c.IsEmpty() {
return mysql.Bool(true)
}
if c.IsComposite() {
return paginateComposite(c)
}
switch idx := c.GenericIndex().(type) {
case *Int64Value:
if c.Direction() == OrderAscending {
return idx.Col().GT(idx.Expr())
}
return idx.Col().LT(idx.Expr())
case *Uint64Value:
if c.Direction() == OrderAscending {
return idx.Col().GT(idx.Expr())
}
return idx.Col().LT(idx.Expr())
case *StringValue:
if c.Direction() == OrderAscending {
return idx.Col().GT(idx.Expr())
}
return idx.Col().LT(idx.Expr())
case *TimestampValue:
if c.Direction() == OrderAscending {
return idx.Col().GT(idx.Expr())
}
return idx.Col().LT(idx.Expr())
default:
// fallback: emit a raw comparison using the column key and the value
ck := c.GenericIndex().ColumnKey()
if ck.IsEmpty() {
return mysql.Bool(true)
}
slog.Warn(
"Cursor pagination conditions generated via raw fallback",
"table", ck.Table, "col", ck.Column,
)
if c.Direction() == OrderAscending {
return mysql.RawBool(
fmt.Sprintf("%s.%s > ?", ck.Table, ck.Column),
mysql.RawArgs{"?": c.GenericIndex()},
)
}
return mysql.RawBool(
fmt.Sprintf("%s.%s < ?", ck.Table, ck.Column),
mysql.RawArgs{"?": c.GenericIndex()},
)
}
}
func paginateComposite(c GenericCursor) mysql.BoolExpression {
comparator := "<"
if c.Direction() == OrderAscending {
comparator = ">"
}
return mysql.RawBool(
compositeTupleSQL(comparator, c),
compositeTupleArgs(c),
)
}
// OrderByClauses returns ORDER BY clauses matching the cursor pagination semantics,
// including tuple ordering when [GenericCursor.UsesTupleOrdering] is true.
func OrderByClauses(c GenericCursor) []mysql.OrderByClause {
if c == nil {
return nil
}
orderCol := c.OrderCol()
if c.UsesTupleOrdering() {
indexCol, err := GetColumnByKey(c.GenericIndex().ColumnKey())
if err != nil {
panic(err)
}
indexOrderCol, ok := indexCol.(CursorOrderCol)
if !ok {
panic(fmt.Errorf("index column %s is not orderable", c.GenericIndex().ColumnKey()))
}
if c.Direction() == OrderAscending {
return []mysql.OrderByClause{orderCol.ASC(), indexOrderCol.ASC()}
}
return []mysql.OrderByClause{orderCol.DESC(), indexOrderCol.DESC()}
}
if c.Direction() == OrderAscending {
return []mysql.OrderByClause{orderCol.ASC()}
}
return []mysql.OrderByClause{orderCol.DESC()}
}