import files, add README & LICENSE

This commit is contained in:
2026-06-29 17:06:34 -07:00
commit aecd90eb84
17 changed files with 2552 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
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.
func PaginateConds[IE mysql.Expression, IC mysql.Column](c *Cursor[IE, IC]) mysql.BoolExpression {
return paginateFromGeneric(c)
}
// PaginateIntConds returns a mysql.BoolExpression that paginates results using
// the provided integer cursor as a base position.
func PaginateIntConds[IE mysql.IntegerExpression, IC mysql.ColumnInteger](c *Cursor[IE, IC]) mysql.BoolExpression {
return paginateFromGeneric(c)
}
// PaginateStringConds returns a mysql.BoolExpression that paginates results using
// the provided string cursor as a base position.
func PaginateStringConds[IE mysql.StringExpression, IC mysql.ColumnString](c *Cursor[IE, IC]) mysql.BoolExpression {
return paginateFromGeneric(c)
}
// PaginateUUIDConds returns a mysql.BoolExpression that paginates results using
// the provided UUID cursor as a base position.
func PaginateUUIDConds[IE mysql.StringExpression, IC mysql.ColumnString](c *Cursor[IE, IC]) mysql.BoolExpression {
return paginateFromGeneric(c)
}
// PaginateTimestampConds returns a mysql.BoolExpression that paginates results
// using the provided timestamp cursor as a base position.
func PaginateTimestampConds[IE mysql.TimestampExpression, IC mysql.ColumnTimestamp](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.
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()}
}