683b0ddbf4
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>
208 lines
5.5 KiB
Go
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()}
|
|
}
|