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
+1
View File
@@ -0,0 +1 @@
coverage.out
+11
View File
@@ -0,0 +1,11 @@
Copyright (c) 2026 Elijah Duffy
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+144
View File
@@ -0,0 +1,144 @@
# cursor
Relay-style cursor pagination for [go-jet](https://github.com/go-jet/jet) MySQL queries.
Encodes opaque cursor strings from column values, applies consistent `WHERE` / `ORDER BY` clauses, and builds GraphQL Relaycompatible `Connection` results with `PageInfo` and total counts.
## Requirements
- Go 1.25+
- [go-jet/v2/mysql](https://github.com/go-jet/jet) — generated table/column types
- [go-toolkit/dbx](https://gitea.auvem.com/go-toolkit/dbx) — `Queryable` for running queries
```bash
go get gitea.auvem.com/go-toolkit/cursor
```
## Setup
Register every column referenced by a cursor at application startup. The registry resolves columns when encoding, decoding, and building SQL.
```go
import (
"gitea.auvem.com/go-toolkit/cursor"
"yourapp/.gen/yourdb/table"
)
func init() {
cursor.RegisterColumn(table.User.ID, table.Meeting.StartTime)
}
```
## Concepts
| Term | Meaning |
|------|---------|
| **Index** | Stable position column (usually the primary key). |
| **Order column** | Column users sort by. May differ from the index. |
| **Simple cursor** | Index and order column are the same; paginate on one value. |
| **Tuple ordering** | Order column ≠ index; results sort by `(order_col, index_col)`. |
| **Composite cursor** | Tuple ordering plus an encoded `OrderValue` on each edge — required for correct `after` pagination when sort values can repeat. |
## Quick start
### 1. Define a cursor factory
```go
func newUserCursor() *cursor.Cursor[mysql.IntegerExpression, mysql.ColumnInteger] {
return cursor.NewCursor(
cursor.NewInt64Value(0, table.User.ID),
table.User.ID,
cursor.OrderDescending,
)
}
```
### 2. Paginate a query
```go
active := newUserCursor()
if after != nil {
_ = active.Decode(*after)
}
stmt := table.User.
SELECT(table.User.AllColumns).
WHERE(filters).
WHERE(cursor.PaginateConds(active)).
ORDER_BY(cursor.OrderByClauses(active)...).
LIMIT(int64(limit))
var rows []*User
dbx.MustQuery(db, stmt, &rows, nil)
```
Or use `PageQuery` to run the query and build edges in one step:
```go
conn, err := cursor.PageQuery[User, mysql.IntegerExpression, mysql.ColumnInteger]{
Sqlo: db,
Stmt: table.User.SELECT(table.User.AllColumns),
Conds: filters,
Cursor: decodedCursor, // nil for first page
Default: newUserCursor,
Limit: limit,
CountFn: cursor.BuildQueryCountFn(table.User.ID, table.User, filters),
ToEdge: func(_ *cursor.Cursor[...], item *User) (cursor.GenericCursor, error) {
return newUserCursor().CopyWithVal(
cursor.NewInt64Value(item.ID, table.User.ID),
), nil
},
}.Run()
```
### 3. Relay `after` / `first` args
```go
conn, err := cursor.ConnectionFromRelayArgs(
after, first, newUserCursor,
func(c *cursor.Cursor[...], limit int) (*cursor.Connection[User], error) {
return listUsers(c, limit)
},
)
```
### 4. Composite (multi-column) sort
When sorting by a non-unique column, include both index and order values on each edge:
```go
base := cursor.NewCursor(
cursor.NewStringValue("", table.Meeting.ID),
table.Meeting.StartTime,
cursor.OrderDescending,
)
edgeCursor := base.CopyWithVals(
cursor.NewStringValue(row.ID, table.Meeting.ID),
cursor.NewTimestampValue(row.StartTime, table.Meeting.StartTime),
)
```
## API overview
| Layer | Types / functions |
|-------|-------------------|
| Column registry | `RegisterColumn`, `RegisterColumnList`, `GetColumn`, `GetColumnByKey` |
| Cursor values | `NewInt64Value`, `NewUint64Value`, `NewStringValue`, `NewTimestampValue` |
| Cursor | `NewCursor`, `CopyWithVal`, `CopyWithVals`, `Encode`, `Decode`, `NewCursorFromAfterPtr` |
| SQL helpers | `PaginateConds`, `OrderByClauses`, `QueryCount`, `BuildQueryCountFn` |
| Relay output | `BuildEdges`, `PageQuery`, `ConnectionFromRelayArgs`, `Connection`, `PageInfo` |
## Value types
| Index / order type | Constructor |
|--------------------|-------------|
| `int64` | `NewInt64Value` |
| `uint64` | `NewUint64Value` |
| `string` / UUID | `NewStringValue` |
| `time.Time` | `NewTimestampValue` |
## Utilities
- `ExtractNodes` — nodes from a `Connection`
- `DereferenceSlice``[]*T``[]T`, skipping nils
+558
View File
@@ -0,0 +1,558 @@
package cursor
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/go-jet/jet/v2/mysql"
)
var (
// ErrBadCursor is returned when a cursor is invalid.
ErrBadCursorString = errors.New("bad cursor string")
columnRegistry = make(map[ColumnKey]mysql.Column)
columnRegistryLock sync.RWMutex
)
// CursorOrderCol is an interface for SQL expression that support the ORDER BY clause.
type CursorOrderCol interface {
mysql.Column
ASC() mysql.OrderByClause
DESC() mysql.OrderByClause
}
// ColumnKey identifies a column and table in the database with a given value.
type ColumnKey struct {
Table string `json:"table"`
Column string `json:"column"`
}
// NewColumnKey creates a new ColumnKey with the specified table and column names.
func NewColumnKey(col mysql.Column) ColumnKey {
return ColumnKey{
Table: col.TableName(),
Column: col.Name(),
}
}
// IsEmpty returns true if the ColumnKey is empty.
func (k *ColumnKey) IsEmpty() bool {
return k == nil || k.Table == "" || k.Column == ""
}
// String returns a string representation of the ColumnKey.
func (k ColumnKey) String() string {
return fmt.Sprintf("%s.%s", k.Table, k.Column)
}
// RegisterColumn registers one or more columns with the cursor registry.
func RegisterColumn(columns ...mysql.Column) {
columnRegistryLock.Lock()
defer columnRegistryLock.Unlock()
for _, col := range columns {
columnRegistry[NewColumnKey(col)] = col
}
}
// RegisterColumnList registers a list of columns with the cursor registry.
func RegisterColumnList(columns mysql.ColumnList) {
RegisterColumn([]mysql.Column(columns)...)
}
// GetColumn retrieves a column from the cursor registry.
func GetColumn(table, column string) (mysql.Column, error) {
columnRegistryLock.RLock()
defer columnRegistryLock.RUnlock()
key := ColumnKey{
Table: table,
Column: column,
}
col, ok := columnRegistry[key]
if !ok {
return nil, fmt.Errorf("column %s.%s not registered", table, column)
}
return col, nil
}
// GetColumnByKey retrieves a column from the cursor registry by its ColumnKey.
func GetColumnByKey(key ColumnKey) (mysql.Column, error) {
columnRegistryLock.RLock()
defer columnRegistryLock.RUnlock()
col, ok := columnRegistry[key]
if !ok {
return nil, fmt.Errorf("column %s.%s not registered", key.Table, key.Column)
}
return col, nil
}
// OrderDirection represents the order direction of a cursor.
type OrderDirection int
const (
// OrderAscending sorts results in ascending order.
OrderAscending OrderDirection = iota
// OrderDescending sorts results in descending order.
OrderDescending
)
// MarshalJSON marshals the OrderDirection to JSON.
func (od OrderDirection) MarshalJSON() ([]byte, error) {
switch od {
case OrderAscending:
return []byte(`"ASC"`), nil
case OrderDescending:
return []byte(`"DESC"`), nil
default:
return nil, fmt.Errorf("invalid order direction: %d", od)
}
}
// UnmarshalJSON unmarshals the OrderDirection from JSON.
func (od *OrderDirection) UnmarshalJSON(data []byte) error {
var dir string
if err := json.Unmarshal(data, &dir); err != nil {
return err
}
switch strings.ToUpper(dir) {
case "ASC":
*od = OrderAscending
case "DESC":
*od = OrderDescending
default:
return fmt.Errorf("invalid order direction: %s", dir)
}
return nil
}
// Compile-time checks to ensure value types implement ExprMarshaler
var _ ExprMarshaler[mysql.StringExpression, mysql.ColumnString] = (*StringValue)(nil)
var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Int64Value)(nil)
var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Uint64Value)(nil)
var _ ExprMarshaler[mysql.TimestampExpression, mysql.ColumnTimestamp] = (*TimestampValue)(nil)
type ExprMarshaler[E mysql.Expression, C mysql.Column] interface {
GenericExpr
Expr() E
Col() C
}
// GenericExpr is the untyped cursor value contract shared by index and order values.
type GenericExpr interface {
ColumnKey() ColumnKey
IsEmpty() bool
driver.Valuer
}
var _ GenericExpr = (*StringValue)(nil)
var _ GenericExpr = (*Int64Value)(nil)
var _ GenericExpr = (*Uint64Value)(nil)
var _ GenericExpr = (*TimestampValue)(nil)
type StringValue struct {
Key ColumnKey `json:"key"`
Val string `json:"val"`
}
func NewStringValue(val string, col mysql.ColumnString) *StringValue {
return &StringValue{
Key: NewColumnKey(col),
Val: val,
}
}
func (s StringValue) Expr() mysql.StringExpression {
return mysql.String(s.Val)
}
func (s StringValue) Col() mysql.ColumnString {
col, err := GetColumnByKey(s.Key)
if err != nil {
panic(err)
}
colStr, ok := col.(mysql.ColumnString)
if !ok {
panic(fmt.Errorf("column %s.%s is not a string column", s.Key.Table, s.Key.Column))
}
return colStr
}
func (s StringValue) Value() (driver.Value, error) {
return s.Val, nil
}
func (s *StringValue) ColumnKey() ColumnKey {
return s.Key
}
func (s *StringValue) IsEmpty() bool {
return s == nil || s.Key.Table == "" || s.Key.Column == "" || s.Val == ""
}
type Int64Value struct {
Key ColumnKey `json:"key"`
Val int64 `json:"val"`
}
func NewInt64Value(val int64, col mysql.ColumnInteger) *Int64Value {
return &Int64Value{
Key: NewColumnKey(col),
Val: val,
}
}
func (i Int64Value) Expr() mysql.IntegerExpression {
return mysql.Int64(i.Val)
}
func (i Int64Value) Col() mysql.ColumnInteger {
col, err := GetColumnByKey(i.Key)
if err != nil {
panic(err)
}
colInt, ok := col.(mysql.ColumnInteger)
if !ok {
panic(fmt.Errorf("column %s.%s is not an integer column", i.Key.Table, i.Key.Column))
}
return colInt
}
func (i Int64Value) Value() (driver.Value, error) {
return i.Val, nil
}
func (i *Int64Value) ColumnKey() ColumnKey {
return i.Key
}
func (i *Int64Value) IsEmpty() bool {
return i == nil || i.Key.Table == "" || i.Key.Column == ""
}
type Uint64Value struct {
Key ColumnKey `json:"key"`
Val uint64 `json:"val"`
}
func NewUint64Value(val uint64, col mysql.ColumnInteger) *Uint64Value {
return &Uint64Value{
Key: NewColumnKey(col),
Val: val,
}
}
func (i Uint64Value) Expr() mysql.IntegerExpression {
return mysql.Uint64(i.Val)
}
func (i Uint64Value) Col() mysql.ColumnInteger {
col, err := GetColumnByKey(i.Key)
if err != nil {
panic(err)
}
colInt, ok := col.(mysql.ColumnInteger)
if !ok {
panic(fmt.Errorf("column %s.%s is not an integer column", i.Key.Table, i.Key.Column))
}
return colInt
}
func (i Uint64Value) Value() (driver.Value, error) {
return i.Val, nil
}
func (i *Uint64Value) ColumnKey() ColumnKey {
return i.Key
}
func (i *Uint64Value) IsEmpty() bool {
return i == nil || i.Key.Table == "" || i.Key.Column == ""
}
// TimestampValue stores a timestamp cursor position.
type TimestampValue struct {
Key ColumnKey `json:"key"`
Val time.Time `json:"val"`
}
// NewTimestampValue creates a timestamp cursor value for the given column.
func NewTimestampValue(val time.Time, col mysql.ColumnTimestamp) *TimestampValue {
return &TimestampValue{
Key: NewColumnKey(col),
Val: val,
}
}
func (t TimestampValue) Expr() mysql.TimestampExpression {
return mysql.TimestampT(t.Val)
}
func (t TimestampValue) Col() mysql.ColumnTimestamp {
col, err := GetColumnByKey(t.Key)
if err != nil {
panic(err)
}
colTS, ok := col.(mysql.ColumnTimestamp)
if !ok {
panic(fmt.Errorf("column %s.%s is not a timestamp column", t.Key.Table, t.Key.Column))
}
return colTS
}
func (t TimestampValue) Value() (driver.Value, error) {
return t.Val, nil
}
func (t *TimestampValue) ColumnKey() ColumnKey {
return t.Key
}
func (t *TimestampValue) IsEmpty() bool {
return t == nil || t.Key.Table == "" || t.Key.Column == "" || t.Val.IsZero()
}
// GenericCursor is an interface for a cursor that can be used with any type of
// expression and column. Only the methods that do not depend on the specific
// types of expressions and columns are defined here.
type GenericCursor interface {
IsEmpty() bool
IsComposite() bool
UsesTupleOrdering() bool
OrderCol() CursorOrderCol
Encode() (string, error)
Decode(src string) error
String() string
GenericIndex() GenericExpr
GenericOrderValue() GenericExpr
Direction() OrderDirection
}
// Compile-time check to ensure that Cursor implements the GenericCursor interface.
var _ GenericCursor = (*Cursor[mysql.Expression, mysql.Column])(nil)
// Cursor identifies a location and order in the database.
//
// Index is the stable position column (typically the primary key). OrderColumnKey
// is the primary sort column shown to users. When they differ and OrderValue is
// set, pagination uses lexicographic tuple comparison on (order_col, index_col).
type Cursor[IE mysql.Expression, IC mysql.Column] struct {
// Index identifies the column used for positioning the cursor and its current value.
Index ExprMarshaler[IE, IC] `json:"index"`
// OrderValue holds the order column value at this cursor position when using
// composite tuple pagination.
OrderValue GenericExpr `json:"order_val,omitempty"`
// OrderColumnKey identifies the column used for ordering the results.
OrderColumnKey ColumnKey `json:"order_col"`
// OrderDir is the direction of the order (ASC or DESC).
OrderDir OrderDirection `json:"order_dir"`
}
type cursorJSON struct {
Index json.RawMessage `json:"index"`
OrderValue json.RawMessage `json:"order_val"`
OrderColumnKey ColumnKey `json:"order_col"`
OrderDir OrderDirection `json:"order_dir"`
}
// NewCursor creates a new Cursor with the specified parameters.
func NewCursor[IE mysql.Expression, IC mysql.Column](index ExprMarshaler[IE, IC], orderCol CursorOrderCol, orderDir OrderDirection) *Cursor[IE, IC] {
return &Cursor[IE, IC]{
Index: index,
OrderColumnKey: NewColumnKey(orderCol),
OrderDir: orderDir,
}
}
// NewCursorFromAfterPtr decodes a Relay-style after cursor. Nil or empty after
// returns (nil, nil).
func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
newZero func() *Cursor[IE, IC],
after *string,
) (*Cursor[IE, IC], error) {
if after == nil || *after == "" {
return nil, nil
}
c := newZero()
return c, c.Decode(*after)
}
// NewCursorFromJSON returns a Cursor from a JSON representation.
func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarshaler[IE, IC], src []byte) (*Cursor[IE, IC], error) {
cursor := Cursor[IE, IC]{
Index: zeroIndex,
}
if err := decodeCursorJSON(&cursor, src, false, OrderAscending); err != nil {
return nil, err
}
return &cursor, nil
}
// CopyWithVal returns a new cursor with the specified value and the current ordering.
func (c *Cursor[IE, IC]) CopyWithVal(val ExprMarshaler[IE, IC]) *Cursor[IE, IC] {
return NewCursor(val, c.OrderCol(), c.OrderDir)
}
// CopyWithVals returns a new cursor with the specified index and order values.
// Use this when IsComposite() is true.
func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal GenericExpr) *Cursor[IE, IC] {
result := NewCursor(index, c.OrderCol(), c.OrderDir)
result.OrderValue = orderVal
return result
}
// UsesTupleOrdering reports whether results are sorted by (order_col, index_col).
// Unlike IsComposite, this does not require OrderValue and applies to default
// cursors on the first page.
func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
if c == nil || c.Index == nil {
return false
}
indexKey := c.Index.ColumnKey()
if indexKey.IsEmpty() || c.OrderColumnKey.IsEmpty() {
return false
}
return c.OrderColumnKey != indexKey
}
// IsComposite reports whether pagination uses (order_col, index_col) tuple comparison.
func (c *Cursor[IE, IC]) IsComposite() bool {
if c == nil || c.Index == nil || c.Index.IsEmpty() {
return false
}
if c.OrderColumnKey == c.Index.ColumnKey() {
return false
}
return c.OrderValue != nil && !c.OrderValue.IsEmpty()
}
// IsEmpty returns true if the cursor or any of its keys are empty or unmapped.
func (c *Cursor[IE, IC]) IsEmpty() bool {
if c == nil || c.Index == nil || c.Index.IsEmpty() {
return true
}
_, err := GetColumnByKey(c.OrderColumnKey)
return err != nil
}
// GenericIndex returns the Index ExprMarshaler as a GenericExpr.
func (c *Cursor[IE, IC]) GenericIndex() GenericExpr {
return c.Index
}
// GenericOrderValue returns the order column value when using composite pagination.
func (c *Cursor[IE, IC]) GenericOrderValue() GenericExpr {
return c.OrderValue
}
// Direction returns the order direction expected by the cursor.
func (c *Cursor[IE, IC]) Direction() OrderDirection {
return c.OrderDir
}
// OrderCol returns the column used for ordering the results. Panics if the
// column is not registered.
func (c *Cursor[IE, IC]) OrderCol() CursorOrderCol {
col, err := GetColumnByKey(c.OrderColumnKey)
if err != nil {
panic(err)
}
return col
}
// Encode returns a stringified JSON representation of the cursor.
func (c *Cursor[IE, IC]) Encode() (string, error) {
bytes, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("failed to marshal cursor: %w", err)
}
return string(bytes), nil
}
// Decode decodes a stringified JSON representation of the cursor into this object.
func (c *Cursor[IE, IC]) Decode(src string) error {
if c == nil {
return fmt.Errorf("cursor is nil")
}
return decodeCursorJSON(c, []byte(src), false, OrderAscending)
}
// DecodeAndOrder decodes a stringified JSON representation of the cursor into
// this object and applies a new order direction.
func (c *Cursor[IE, IC]) DecodeAndOrder(src string, orderDir OrderDirection) error {
return decodeCursorJSON(c, []byte(src), true, orderDir)
}
func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
c *Cursor[IE, IC],
src []byte,
overrideDir bool,
orderDir OrderDirection,
) error {
var raw cursorJSON
if err := json.Unmarshal(src, &raw); err != nil {
return fmt.Errorf("failed to unmarshal cursor: %w", err)
}
if err := json.Unmarshal(raw.Index, c.Index); err != nil {
return fmt.Errorf("failed to unmarshal cursor index: %w", err)
}
c.OrderColumnKey = raw.OrderColumnKey
c.OrderDir = raw.OrderDir
if overrideDir {
c.OrderDir = orderDir
}
if len(raw.OrderValue) > 0 {
orderVal, err := unmarshalGenericExpr(raw.OrderValue, raw.OrderColumnKey)
if err != nil {
return fmt.Errorf("failed to unmarshal cursor order value: %w", err)
}
c.OrderValue = orderVal
} else {
c.OrderValue = nil
}
return nil
}
func unmarshalGenericExpr(data []byte, key ColumnKey) (GenericExpr, error) {
col, err := GetColumnByKey(key)
if err != nil {
return nil, err
}
switch col.(type) {
case mysql.ColumnTimestamp:
var v TimestampValue
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
case mysql.ColumnInteger:
var v Uint64Value
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
case mysql.ColumnString:
var v StringValue
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
default:
return nil, fmt.Errorf("unsupported order column type for %s", key)
}
}
// String returns a string representation of the Cursor.
func (c *Cursor[IE, IC]) String() string {
bytes, err := json.Marshal(c)
if err != nil {
return ""
}
return string(bytes)
}
+293
View File
@@ -0,0 +1,293 @@
package cursor
import (
"encoding/json"
"testing"
"time"
"github.com/go-jet/jet/v2/mysql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestColumnKey(t *testing.T) {
assert := assert.New(t)
empty := ColumnKey{}
assert.True(empty.IsEmpty())
assert.Equal("user.id", NewColumnKey(User.ID).String())
columnKey := NewColumnKey(User.ID)
assert.Equal(User.TableName(), columnKey.Table)
assert.Equal(User.ID.Name(), columnKey.Column)
assert.False(columnKey.IsEmpty())
bytes, err := json.Marshal(columnKey)
assert.NoError(err)
assert.Equal(`{"table":"user","column":"id"}`, string(bytes))
var res ColumnKey
err = json.Unmarshal(bytes, &res)
assert.NoError(err)
assert.Equal(User.TableName(), res.Table)
assert.Equal(User.ID.Name(), res.Column)
}
func TestColumnRegistry(t *testing.T) {
assert := assert.New(t)
RegisterColumn(User.ID)
col, err := GetColumn(User.TableName(), User.ID.Name())
assert.NoError(err)
assert.Equal(User.ID, col)
_, err = GetColumn(User.TableName(), "nonexistent")
assert.Error(err)
col, err = GetColumnByKey(ColumnKey{
Table: User.TableName(),
Column: User.ID.Name(),
})
assert.NoError(err)
assert.Equal(User.ID, col)
_, err = GetColumnByKey(ColumnKey{
Table: User.TableName(),
Column: "nonexistent",
})
assert.Error(err)
_, err = GetColumn(User.TableName(), User.CreatedAt.Name())
assert.Error(err)
}
func TestRegisterColumnList(t *testing.T) {
assert := assert.New(t)
RegisterColumnList(Address.AllColumns)
col, err := GetColumn(Address.TableName(), Address.ID.Name())
assert.NoError(err)
assert.Equal(Address.ID, col)
}
func TestOrderDirection(t *testing.T) {
assert := assert.New(t)
bytes, err := json.Marshal(OrderAscending)
assert.NoError(err)
assert.Equal(`"ASC"`, string(bytes))
var res OrderDirection
err = json.Unmarshal(bytes, &res)
assert.NoError(err)
assert.Equal(OrderAscending, res)
bytes, err = json.Marshal(OrderDescending)
assert.NoError(err)
assert.Equal(`"DESC"`, string(bytes))
err = json.Unmarshal(bytes, &res)
assert.NoError(err)
assert.Equal(OrderDescending, res)
var invalid OrderDirection
err = json.Unmarshal([]byte(`"INVALID"`), &invalid)
assert.Error(err)
_, err = OrderDirection(99).MarshalJSON()
assert.Error(err)
}
func TestInt64Value(t *testing.T) {
assert := assert.New(t)
v := NewInt64Value(1, Scratch.ID)
assert.Equal(int64(1), v.Val)
assert.Equal(Scratch.ID.Name(), v.Key.Column)
assert.Equal(Scratch.TableName(), v.Key.Table)
assert.Equal(mysql.Int64(1), v.Expr())
assert.Panics(func() { v.Col() })
RegisterColumn(Scratch.ID)
assert.Equal(Scratch.ID, v.Col())
assert.False(v.IsEmpty())
bytes, err := json.Marshal(v)
assert.NoError(err)
assert.Equal(`{"key":{"table":"scratch","column":"id"},"val":1}`, string(bytes))
var res Int64Value
assert.True(res.IsEmpty())
err = json.Unmarshal(bytes, &res)
assert.NoError(err)
assert.Equal(int64(1), res.Val)
assert.Equal(Scratch.ID.Name(), res.Key.Column)
assert.Equal(Scratch.TableName(), res.Key.Table)
assert.Equal(mysql.Int64(1), res.Expr())
assert.Equal(Scratch.ID, v.Col())
}
func TestUint64Value(t *testing.T) {
assert := assert.New(t)
v := NewUint64Value(42, Spare.ID)
assert.Equal(uint64(42), v.Val)
assert.Equal(mysql.Uint64(42), v.Expr())
assert.Panics(func() { v.Col() })
RegisterColumn(Spare.ID)
assert.Equal(Spare.ID, v.Col())
assert.False(v.IsEmpty())
bytes, err := json.Marshal(v)
assert.NoError(err)
var res Uint64Value
assert.True(res.IsEmpty())
err = json.Unmarshal(bytes, &res)
assert.NoError(err)
assert.Equal(uint64(42), res.Val)
}
func TestStringValueIsEmpty(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
empty := NewStringValue("", Meeting.ID)
assert.True(empty.IsEmpty())
nonEmpty := NewStringValue("meeting-uuid-1", Meeting.ID)
assert.False(nonEmpty.IsEmpty())
defaultMeetingCursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
assert.True(defaultMeetingCursor.IsEmpty())
}
func TestNewCursor(t *testing.T) {
assert := assert.New(t)
empty := Cursor[mysql.IntegerExpression, mysql.ColumnInteger]{}
assert.True(empty.IsEmpty())
v := NewInt64Value(1, User.ID)
cursor := NewCursor(v, User.ID, OrderAscending)
assert.Equal(v, cursor.Index)
assert.Equal(NewColumnKey(User.ID), cursor.OrderColumnKey)
assert.Equal(OrderAscending, cursor.OrderDir)
bytes, err := json.Marshal(cursor)
assert.NoError(err)
assert.Equal(`{"index":{"key":{"table":"user","column":"id"},"val":1},"order_col":{"table":"user","column":"id"},"order_dir":"ASC"}`, string(bytes))
}
func TestCopyWithVal(t *testing.T) {
assert := assert.New(t)
RegisterColumn(User.ID)
original := NewCursor(NewInt64Value(1, User.ID), User.ID, OrderDescending)
copied := original.CopyWithVal(NewInt64Value(99, User.ID))
assert.Equal(int64(99), copied.Index.(*Int64Value).Val)
assert.Equal(OrderDescending, copied.OrderDir)
assert.Equal(NewColumnKey(User.ID), copied.OrderColumnKey)
}
func TestNewCursorFromJSON(t *testing.T) {
assert := assert.New(t)
v := NewInt64Value(1, User.ID)
cursor := NewCursor(v, User.ID, OrderDescending)
bytes, err := json.Marshal(cursor)
assert.NoError(err)
zv := NewInt64Value(0, User.ID)
res, err := NewCursorFromJSON(zv, bytes)
assert.NoError(err)
assert.NotNil(res)
assert.Equal(int64(1), res.Index.(*Int64Value).Val)
assert.Equal(NewColumnKey(User.ID), res.OrderColumnKey)
assert.Equal(OrderDescending, res.OrderDir)
}
func TestNewCursorFromJSONComposite(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
original := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
bytes, err := json.Marshal(original)
require.NoError(t, err)
zv := NewStringValue("", Meeting.ID)
res, err := NewCursorFromJSON(zv, bytes)
require.NoError(t, err)
assert.True(res.IsComposite())
assert.Equal("meeting-uuid-1", res.Index.(*StringValue).Val)
assert.Equal(when, res.GenericOrderValue().(*TimestampValue).Val)
}
func TestDecodeAndOrder(t *testing.T) {
assert := assert.New(t)
RegisterColumn(User.ID)
encoded, err := NewCursor(
NewInt64Value(5, User.ID),
User.ID,
OrderDescending,
).Encode()
require.NoError(t, err)
c := NewCursor(NewInt64Value(0, User.ID), User.ID, OrderAscending)
require.NoError(t, c.DecodeAndOrder(encoded, OrderAscending))
assert.Equal(OrderAscending, c.OrderDir)
assert.Equal(int64(5), c.Index.(*Int64Value).Val)
}
func TestCursorDecodeNilReceiver(t *testing.T) {
var c *Cursor[mysql.IntegerExpression, mysql.ColumnInteger]
assert.Error(t, c.Decode("{}"))
}
func TestNewCursorFromAfterPtr(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
RegisterColumn(User.ID)
newZero := func() *Cursor[mysql.IntegerExpression, mysql.ColumnInteger] {
return NewCursor(NewInt64Value(0, User.ID), User.ID, OrderDescending)
}
c, err := NewCursorFromAfterPtr(newZero, nil)
require.NoError(err)
assert.Nil(c)
empty := ""
c, err = NewCursorFromAfterPtr(newZero, &empty)
require.NoError(err)
assert.Nil(c)
encoded, err := NewCursor(
NewInt64Value(42, User.ID),
User.ID,
OrderDescending,
).Encode()
require.NoError(err)
c, err = NewCursorFromAfterPtr(newZero, &encoded)
require.NoError(err)
require.NotNil(c)
assert.Equal(int64(42), c.Index.(*Int64Value).Val)
}
+121
View File
@@ -0,0 +1,121 @@
package cursor
import (
"fmt"
"gitea.auvem.com/go-toolkit/dbx"
)
// PageInfo holds standard pagination information, including cursors for the
// start and end of the page, and flags indicating whether there are more pages
// before or after the current page.
//
// Modeled after the PageInfo type in the GraphQL Relay specification.
type PageInfo struct {
// The cursor string for the last item in the current page.
EndCursor *string `json:"endCursor,omitempty"`
// Whether there are more items after the current page.
HasNextPage bool `json:"hasNextPage"`
// Whether there are more items before the current page.
HasPreviousPage bool `json:"hasPreviousPage"`
// The cursor string for the first item in the current page.
StartCursor *string `json:"startCursor,omitempty"`
}
// CursorFunc is a function that takes a pointer to an object type T, the
// underlying database view object, and returns a GenericCursor or an error.
type CursorFunc[T any] = func(*T) (GenericCursor, error)
// Edge represents a single edge in a connection, containing a node of type T
// and a cursor string for pagination.
type Edge[T any] struct {
Node *T
Cursor string
}
// Connection represents a paginated list of edges, along with page information.
type Connection[T any] struct {
Edges []*Edge[T]
PageInfo *PageInfo
TotalCount int
}
// BuildEdges constructs a Connection object from a list of items of type T,
// using the provided Queryable interface for database operations and a
// count function to determine the total number of items. It also uses a
// cursor function to generate cursors for each item in the list.
func BuildEdges[T any](
sqlo dbx.Queryable,
countFn QueryCountFn,
list []*T,
cursorFunc CursorFunc[T],
) (
conn *Connection[T],
err error,
) {
// Create edges for the connection
edges := make([]*Edge[T], len(list))
for i, item := range list {
if item == nil {
return nil, fmt.Errorf("item at index %d is nil", i)
}
c, err := cursorFunc(item)
if err != nil {
return nil, fmt.Errorf("failed to get cursor for item at index %d: %w", i, err)
}
cstr, err := c.Encode()
if err != nil {
return nil, fmt.Errorf("failed to encode cursor for item at index %d: %w", i, err)
}
edges[i] = &Edge[T]{
Node: item,
Cursor: cstr,
}
}
// Create page info and connection object
var pageInfo PageInfo
var countResult QueryCountResult
if len(edges) > 0 {
// Set start and end cursor strings
pageInfo.StartCursor = &edges[0].Cursor
pageInfo.EndCursor = &edges[len(edges)-1].Cursor
// Fetch total count. Comparisons use composite tuple logic when the cursor
// encodes both order and index columns (see meeting pagination).
startCursor, err := cursorFunc(edges[0].Node)
if err != nil {
return nil, fmt.Errorf("failed to get start cursor: %w", err)
}
endCursor, err := cursorFunc(edges[len(edges)-1].Node)
if err != nil {
return nil, fmt.Errorf("failed to get end cursor: %w", err)
}
if countResult, err = countFn(sqlo, startCursor, endCursor); err != nil {
return nil, fmt.Errorf("failed to query count: %w", err)
}
}
pageInfo.HasNextPage = countResult.After > 0
pageInfo.HasPreviousPage = countResult.Before > 0
return &Connection[T]{
Edges: edges,
PageInfo: &pageInfo,
TotalCount: countResult.Total,
}, nil
}
// ExtractNodes extracts the nodes from a Connection object and returns them
// as a slice of pointers to type T.
func ExtractNodes[T any](conn *Connection[T]) []*T {
nodes := make([]*T, len(conn.Edges))
for i, edge := range conn.Edges {
nodes[i] = edge.Node
}
return nodes
}
+144
View File
@@ -0,0 +1,144 @@
package cursor
import (
"errors"
"testing"
"time"
"gitea.auvem.com/go-toolkit/dbx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildEdgesHasNextPageTupleOrdering(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 23, 5, 0, 0, 0, time.UTC)
base := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
type meetingRow struct {
id string
start time.Time
}
items := []*meetingRow{
{id: "3FeXKOY42znOmyxxq3IEk5LJg98", start: time.Date(2026, 6, 26, 2, 0, 0, 0, time.UTC)},
{id: "3FWPfH9K7SOAikWv67BgKniF9E4", start: when},
{id: "3FWPabTpRGdmTydnULNbKpWrWhG", start: when},
}
cursorFunc := func(row *meetingRow) (GenericCursor, error) {
return base.CopyWithVals(
NewStringValue(row.id, Meeting.ID),
NewTimestampValue(row.start, Meeting.StartTime),
), nil
}
var endCursor GenericCursor
countFn := func(_ dbx.Queryable, _, end GenericCursor) (QueryCountResult, error) {
endCursor = end
return QueryCountResult{Total: 3, After: 0}, nil
}
conn, err := BuildEdges(nil, countFn, items, cursorFunc)
require.NoError(err)
require.NotNil(conn)
assert.False(conn.PageInfo.HasNextPage)
require.NotNil(endCursor)
assert.Equal("3FWPabTpRGdmTydnULNbKpWrWhG", endCursor.GenericIndex().(*StringValue).Val)
}
func TestBuildEdgesEmptyList(t *testing.T) {
countFn := func(_ dbx.Queryable, _, _ GenericCursor) (QueryCountResult, error) {
t.Fatal("countFn should not be called for empty list")
return QueryCountResult{}, nil
}
conn, err := BuildEdges(nil, countFn, []*int{}, func(*int) (GenericCursor, error) {
return nil, errors.New("unreachable")
})
require.NoError(t, err)
require.NotNil(t, conn)
assert.Empty(t, conn.Edges)
assert.False(t, conn.PageInfo.HasNextPage)
assert.False(t, conn.PageInfo.HasPreviousPage)
assert.Equal(t, 0, conn.TotalCount)
}
func TestBuildEdgesPaginationFlags(t *testing.T) {
RegisterColumn(User.ID)
cursorFunc := func(id *int) (GenericCursor, error) {
return NewCursor(NewInt64Value(int64(*id), User.ID), User.ID, OrderDescending), nil
}
id1, id2 := 1, 2
conn, err := BuildEdges(nil, func(_ dbx.Queryable, _, _ GenericCursor) (QueryCountResult, error) {
return QueryCountResult{Total: 10, After: 5, Before: 3}, nil
}, []*int{&id1, &id2}, cursorFunc)
require.NoError(t, err)
assert.True(t, conn.PageInfo.HasNextPage)
assert.True(t, conn.PageInfo.HasPreviousPage)
assert.Equal(t, 10, conn.TotalCount)
require.NotNil(t, conn.PageInfo.StartCursor)
require.NotNil(t, conn.PageInfo.EndCursor)
}
func TestBuildEdgesErrors(t *testing.T) {
RegisterColumn(User.ID)
cursorFunc := func(id *int) (GenericCursor, error) {
return NewCursor(NewInt64Value(int64(*id), User.ID), User.ID, OrderDescending), nil
}
countFn := func(_ dbx.Queryable, _, _ GenericCursor) (QueryCountResult, error) {
return QueryCountResult{}, nil
}
t.Run("nil item", func(t *testing.T) {
id := 1
_, err := BuildEdges(nil, countFn, []*int{&id, nil}, cursorFunc)
require.Error(t, err)
assert.Contains(t, err.Error(), "item at index 1 is nil")
})
t.Run("cursor func error", func(t *testing.T) {
cursorErr := errors.New("cursor failed")
id := 1
_, err := BuildEdges(nil, countFn, []*int{&id}, func(*int) (GenericCursor, error) {
return nil, cursorErr
})
require.Error(t, err)
assert.ErrorIs(t, err, cursorErr)
})
t.Run("count fn error", func(t *testing.T) {
countErr := errors.New("count failed")
id := 1
_, err := BuildEdges(nil, func(_ dbx.Queryable, _, _ GenericCursor) (QueryCountResult, error) {
return QueryCountResult{}, countErr
}, []*int{&id}, cursorFunc)
require.Error(t, err)
assert.ErrorIs(t, err, countErr)
})
}
func TestExtractNodes(t *testing.T) {
a, b := 1, 2
conn := &Connection[int]{
Edges: []*Edge[int]{
{Node: &a, Cursor: "c1"},
{Node: &b, Cursor: "c2"},
},
}
nodes := ExtractNodes(conn)
require.Len(t, nodes, 2)
assert.Equal(t, 1, *nodes[0])
assert.Equal(t, 2, *nodes[1])
}
+136
View File
@@ -0,0 +1,136 @@
package cursor
import (
"testing"
"time"
"github.com/go-jet/jet/v2/mysql"
)
var (
User = newUserTable("", "user", "")
Address = newAddressTable("", "address", "")
Meeting = newMeetingTable("", "meeting", "")
MeetingRoom = newMeetingRoomTable("", "meeting_room", "")
Scratch = newScratchTable("", "scratch", "")
Spare = newSpareTable("", "spare", "")
)
type userTable struct {
mysql.Table
ID mysql.ColumnInteger
CreatedAt mysql.ColumnTimestamp
AllColumns mysql.ColumnList
}
func newUserTable(schemaName, tableName, alias string) userTable {
idCol := mysql.IntegerColumn("id")
createdAtCol := mysql.TimestampColumn("created_at")
allColumns := mysql.ColumnList{idCol, createdAtCol}
return userTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
CreatedAt: createdAtCol,
AllColumns: allColumns,
}
}
type addressTable struct {
mysql.Table
ID mysql.ColumnInteger
AllColumns mysql.ColumnList
}
func newAddressTable(schemaName, tableName, alias string) addressTable {
idCol := mysql.IntegerColumn("id")
allColumns := mysql.ColumnList{idCol}
return addressTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
AllColumns: allColumns,
}
}
type meetingTable struct {
mysql.Table
ID mysql.ColumnString
StartTime mysql.ColumnTimestamp
AllColumns mysql.ColumnList
}
func newMeetingTable(schemaName, tableName, alias string) meetingTable {
idCol := mysql.StringColumn("id")
startTimeCol := mysql.TimestampColumn("start_time")
allColumns := mysql.ColumnList{idCol, startTimeCol}
return meetingTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
StartTime: startTimeCol,
AllColumns: allColumns,
}
}
type meetingRoomTable struct {
mysql.Table
ID mysql.ColumnString
AllColumns mysql.ColumnList
}
func newMeetingRoomTable(schemaName, tableName, alias string) meetingRoomTable {
idCol := mysql.StringColumn("id")
allColumns := mysql.ColumnList{idCol}
return meetingRoomTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
AllColumns: allColumns,
}
}
type scratchTable struct {
mysql.Table
ID mysql.ColumnInteger
AllColumns mysql.ColumnList
}
func newScratchTable(schemaName, tableName, alias string) scratchTable {
idCol := mysql.IntegerColumn("id")
allColumns := mysql.ColumnList{idCol}
return scratchTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
AllColumns: allColumns,
}
}
type spareTable struct {
mysql.Table
ID mysql.ColumnInteger
AllColumns mysql.ColumnList
}
func newSpareTable(schemaName, tableName, alias string) spareTable {
idCol := mysql.IntegerColumn("id")
allColumns := mysql.ColumnList{idCol}
return spareTable{
Table: mysql.NewTable(schemaName, tableName, alias, allColumns...),
ID: idCol,
AllColumns: allColumns,
}
}
func registerMeetingCursorColumns(t *testing.T) {
t.Helper()
RegisterColumn(Meeting.ID, Meeting.StartTime)
}
func mysqlSELECTWhere(cond mysql.BoolExpression) mysql.SelectStatement {
return Meeting.SELECT(Meeting.AllColumns).WHERE(cond)
}
func mustTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
+22
View File
@@ -0,0 +1,22 @@
module gitea.auvem.com/go-toolkit/cursor
go 1.25.4
require (
gitea.auvem.com/go-toolkit/dbx v0.0.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/go-jet/jet/v2 v2.13.0
github.com/stretchr/testify v1.10.0
)
require (
gitea.auvem.com/go-toolkit/app v0.0.0-20250530181559-231561c92698 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace gitea.auvem.com/go-toolkit/dbx => ../dbx
+30
View File
@@ -0,0 +1,30 @@
gitea.auvem.com/go-toolkit/app v0.0.0-20250530181559-231561c92698 h1:Cin4hlLlcGhj05cQX1Ik6EMknzfNVGzzG8u/JnJ0uUE=
gitea.auvem.com/go-toolkit/app v0.0.0-20250530181559-231561c92698/go.mod h1:a7ENpOxndUdONE6oZ9MZAvG1ba2uq01x/LtcnDkpOj8=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jet/jet/v2 v2.13.0 h1:DcD2IJRGos+4X40IQRV6S6q9onoOfZY/GPdvU6ImZcQ=
github.com/go-jet/jet/v2 v2.13.0/go.mod h1:YhT75U1FoYAxFOObbQliHmXVYQeffkBKWT7ZilZ3zPc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+83
View File
@@ -0,0 +1,83 @@
package cursor
import (
"fmt"
"gitea.auvem.com/go-toolkit/dbx"
"github.com/go-jet/jet/v2/mysql"
)
// PageQuery runs a paginated SELECT and builds a Connection from the results.
type PageQuery[T any, IE mysql.Expression, IC mysql.Column] struct {
Sqlo dbx.Queryable
Stmt mysql.SelectStatement
Conds mysql.BoolExpression
Cursor *Cursor[IE, IC]
Default func() *Cursor[IE, IC]
Limit int
CountFn QueryCountFn
ToEdge func(active *Cursor[IE, IC], item *T) (GenericCursor, error)
// Scan runs the paginated query into dest. When nil, Run uses Stmt.Query.
Scan func(stmt mysql.SelectStatement, dest *[]*T) error
// AfterScan optionally transforms rows after the query and before building edges.
AfterScan func(items []*T) ([]*T, error)
}
// Run executes the paginated query and returns a Connection.
func (q PageQuery[T, IE, IC]) Run() (*Connection[T], error) {
active := q.Cursor
if active == nil {
active = q.Default()
}
stmt := q.Stmt
stmt.WHERE(q.Conds.AND(PaginateConds(q.Cursor)))
stmt.ORDER_BY(OrderByClauses(active)...)
if q.Limit > 0 {
stmt = stmt.LIMIT(int64(q.Limit))
}
var items []*T
scan := q.Scan
if scan == nil {
scan = func(s mysql.SelectStatement, dest *[]*T) error {
return s.Query(q.Sqlo, dest)
}
}
if err := scan(stmt, &items); err != nil {
return nil, fmt.Errorf("failed to run paginated query: %w", err)
}
if q.AfterScan != nil {
var err error
items, err = q.AfterScan(items)
if err != nil {
return nil, err
}
}
return BuildEdges(q.Sqlo, q.CountFn, items, func(item *T) (GenericCursor, error) {
return q.ToEdge(active, item)
})
}
// ConnectionFromRelayArgs parses Relay pagination args and calls list with the
// decoded cursor and limit. newZero supplies the cursor template for decoding
// (same as NewXCursor).
func ConnectionFromRelayArgs[T any, IE mysql.Expression, IC mysql.Column](
after *string,
first *int,
newZero func() *Cursor[IE, IC],
list func(cursor *Cursor[IE, IC], limit int) (*Connection[T], error),
) (*Connection[T], error) {
limit := 0
if first != nil {
limit = *first
}
c, err := NewCursorFromAfterPtr(newZero, after)
if err != nil {
return nil, err
}
return list(c, limit)
}
+260
View File
@@ -0,0 +1,260 @@
package cursor
import (
"errors"
"strings"
"testing"
"time"
"gitea.auvem.com/go-toolkit/dbx"
"github.com/go-jet/jet/v2/mysql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConnectionFromRelayArgs(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
RegisterColumn(User.ID)
newZero := func() *Cursor[mysql.IntegerExpression, mysql.ColumnInteger] {
return NewCursor(NewInt64Value(0, User.ID), User.ID, OrderDescending)
}
var gotCursor *Cursor[mysql.IntegerExpression, mysql.ColumnInteger]
var gotLimit int
list := func(c *Cursor[mysql.IntegerExpression, mysql.ColumnInteger], limit int) (*Connection[int], error) {
gotCursor = c
gotLimit = limit
return &Connection[int]{}, nil
}
conn, err := ConnectionFromRelayArgs(nil, nil, newZero, list)
require.NoError(err)
require.NotNil(conn)
assert.Nil(gotCursor)
assert.Equal(0, gotLimit)
first := 25
conn, err = ConnectionFromRelayArgs(nil, &first, newZero, list)
require.NoError(err)
require.NotNil(conn)
assert.Nil(gotCursor)
assert.Equal(25, gotLimit)
encoded, err := NewCursor(
NewInt64Value(7, User.ID),
User.ID,
OrderDescending,
).Encode()
require.NoError(err)
conn, err = ConnectionFromRelayArgs(&encoded, &first, newZero, list)
require.NoError(err)
require.NotNil(conn)
require.NotNil(gotCursor)
assert.Equal(int64(7), gotCursor.Index.(*Int64Value).Val)
assert.Equal(25, gotLimit)
bad := "{not-json"
_, err = ConnectionFromRelayArgs(&bad, nil, newZero, list)
require.Error(err)
listErr := errors.New("list failed")
_, err = ConnectionFromRelayArgs(nil, nil, newZero, func(*Cursor[mysql.IntegerExpression, mysql.ColumnInteger], int) (*Connection[int], error) {
return nil, listErr
})
require.ErrorIs(err, listErr)
}
func TestPageQueryRunSQLComposite(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
active := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
stmt := mysql.SELECT(Meeting.AllColumns).FROM(Meeting)
stmt.WHERE(mysql.Bool(true).AND(PaginateConds(active)))
stmt.ORDER_BY(OrderByClauses(active)...)
sql := stmt.DebugSql()
assert.Contains(sql, "(meeting.start_time, meeting.id) < ('")
assert.True(
strings.Contains(sql, "start_time DESC") && strings.Contains(sql, "id DESC"),
"expected composite order by, got: %s", sql,
)
}
func TestPageQueryRunSQLSimple(t *testing.T) {
assert := assert.New(t)
RegisterColumn(MeetingRoom.ID)
active := NewCursor(
NewStringValue("room-1", MeetingRoom.ID),
MeetingRoom.ID,
OrderDescending,
)
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
stmt.WHERE(mysql.Bool(true).AND(PaginateStringConds(active)))
stmt.ORDER_BY(OrderByClauses(active)...)
sql := stmt.DebugSql()
assert.Contains(sql, "meeting_room.id < 'room-1'")
assert.Contains(sql, "id DESC")
}
func TestPageQueryRunSQLFirstPageNilCursor(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
defaultCursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
var nilCursor *Cursor[mysql.StringExpression, mysql.ColumnString]
stmt := mysql.SELECT(Meeting.AllColumns).FROM(Meeting)
stmt.WHERE(mysql.Bool(true).AND(PaginateConds(nilCursor)))
stmt.ORDER_BY(OrderByClauses(defaultCursor)...)
sql := stmt.DebugSql()
assert.NotContains(sql, "meeting.id < ''")
assert.Contains(sql, "start_time DESC")
assert.Contains(sql, "id DESC")
}
func TestPageQueryRunSQLFirstPageDefaultCursor(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
defaultCursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
stmt := mysql.SELECT(Meeting.AllColumns).FROM(Meeting)
stmt.WHERE(mysql.Bool(true).AND(PaginateConds(defaultCursor)))
stmt.ORDER_BY(OrderByClauses(defaultCursor)...)
sql := stmt.DebugSql()
assert.NotContains(sql, "meeting.id < ''")
assert.Contains(sql, "start_time DESC")
assert.Contains(sql, "id DESC")
}
func TestPageQueryRunSQLFirstPageRoomDefaultCursor(t *testing.T) {
assert := assert.New(t)
RegisterColumn(MeetingRoom.ID)
defaultCursor := NewCursor(
NewStringValue("", MeetingRoom.ID),
MeetingRoom.ID,
OrderDescending,
)
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
stmt.WHERE(mysql.Bool(true).AND(PaginateStringConds(defaultCursor)))
stmt.ORDER_BY(OrderByClauses(defaultCursor)...)
sql := stmt.DebugSql()
assert.NotContains(sql, "meeting_room.id < ''")
assert.Contains(sql, "id DESC")
}
func TestPageQueryRun(t *testing.T) {
RegisterColumn(User.ID)
type row struct {
ID int64
}
defaultCursor := func() *Cursor[mysql.IntegerExpression, mysql.ColumnInteger] {
return NewCursor(NewInt64Value(0, User.ID), User.ID, OrderDescending)
}
items := []*row{{ID: 1}, {ID: 2}}
scanErr := errors.New("scan failed")
afterScanErr := errors.New("after scan failed")
t.Run("success with default cursor and after scan", func(t *testing.T) {
var scannedStmt mysql.SelectStatement
q := PageQuery[row, mysql.IntegerExpression, mysql.ColumnInteger]{
Stmt: User.SELECT(User.AllColumns).FROM(User),
Conds: mysql.Bool(true),
Default: defaultCursor,
Limit: 10,
CountFn: func(_ dbx.Queryable, _, _ GenericCursor) (QueryCountResult, error) {
return QueryCountResult{Total: 2, After: 1, Before: 0}, nil
},
Scan: func(stmt mysql.SelectStatement, dest *[]*row) error {
scannedStmt = stmt
*dest = items
return nil
},
AfterScan: func(rows []*row) ([]*row, error) {
return rows[:1], nil
},
ToEdge: func(_ *Cursor[mysql.IntegerExpression, mysql.ColumnInteger], item *row) (GenericCursor, error) {
return NewCursor(NewInt64Value(item.ID, User.ID), User.ID, OrderDescending), nil
},
}
conn, err := q.Run()
require.NoError(t, err)
require.NotNil(t, conn)
assert.Len(t, conn.Edges, 1)
assert.True(t, conn.PageInfo.HasNextPage)
assert.False(t, conn.PageInfo.HasPreviousPage)
assert.Equal(t, 2, conn.TotalCount)
assert.Contains(t, scannedStmt.DebugSql(), "LIMIT 10")
})
t.Run("scan error", func(t *testing.T) {
q := PageQuery[row, mysql.IntegerExpression, mysql.ColumnInteger]{
Stmt: User.SELECT(User.AllColumns).FROM(User),
Conds: mysql.Bool(true),
Default: defaultCursor,
Scan: func(_ mysql.SelectStatement, _ *[]*row) error {
return scanErr
},
ToEdge: func(_ *Cursor[mysql.IntegerExpression, mysql.ColumnInteger], item *row) (GenericCursor, error) {
return NewCursor(NewInt64Value(item.ID, User.ID), User.ID, OrderDescending), nil
},
}
_, err := q.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to run paginated query")
})
t.Run("after scan error", func(t *testing.T) {
q := PageQuery[row, mysql.IntegerExpression, mysql.ColumnInteger]{
Stmt: User.SELECT(User.AllColumns).FROM(User),
Conds: mysql.Bool(true),
Default: defaultCursor,
Scan: func(_ mysql.SelectStatement, dest *[]*row) error {
*dest = items
return nil
},
AfterScan: func(_ []*row) ([]*row, error) {
return nil, afterScanErr
},
ToEdge: func(_ *Cursor[mysql.IntegerExpression, mysql.ColumnInteger], item *row) (GenericCursor, error) {
return NewCursor(NewInt64Value(item.ID, User.ID), User.ID, OrderDescending), nil
},
}
_, err := q.Run()
require.ErrorIs(t, err, afterScanErr)
})
}
+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()}
}
+177
View File
@@ -0,0 +1,177 @@
package cursor
import (
"context"
"database/sql"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/go-jet/jet/v2/mysql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValueMethods(t *testing.T) {
RegisterColumn(Scratch.ID)
intVal, err := NewInt64Value(7, Scratch.ID).Value()
require.NoError(t, err)
assert.Equal(t, int64(7), intVal)
uintVal, err := NewUint64Value(9, Spare.ID).Value()
require.NoError(t, err)
assert.Equal(t, uint64(9), uintVal)
}
func TestCursorString(t *testing.T) {
RegisterColumn(User.ID)
c := NewCursor(NewInt64Value(3, User.ID), User.ID, OrderAscending)
assert.JSONEq(t, `{"index":{"key":{"table":"user","column":"id"},"val":3},"order_col":{"table":"user","column":"id"},"order_dir":"ASC"}`, c.String())
}
func TestUsesTupleOrderingEdgeCases(t *testing.T) {
assert.False(t, (*Cursor[mysql.IntegerExpression, mysql.ColumnInteger])(nil).UsesTupleOrdering())
RegisterColumn(User.ID)
sameCol := NewCursor(NewInt64Value(0, User.ID), User.ID, OrderDescending)
assert.False(t, sameCol.UsesTupleOrdering())
}
func TestPaginateWrapperFuncs(t *testing.T) {
RegisterColumn(User.ID, Meeting.StartTime)
intCursor := NewCursor(NewInt64Value(1, User.ID), User.ID, OrderDescending)
sql := User.SELECT(User.AllColumns).WHERE(PaginateIntConds(intCursor)).DebugSql()
assert.Contains(t, sql, "user.id < 1")
strCursor := NewCursor(NewStringValue("x", MeetingRoom.ID), MeetingRoom.ID, OrderDescending)
RegisterColumn(MeetingRoom.ID)
sql = MeetingRoom.SELECT(MeetingRoom.AllColumns).WHERE(PaginateUUIDConds(strCursor)).DebugSql()
assert.Contains(t, sql, "meeting_room.id < 'x'")
tsCursor := NewCursor(
NewTimestampValue(mustTime("2026-01-01T00:00:00Z"), Meeting.StartTime),
Meeting.StartTime,
OrderDescending,
)
sql = Meeting.SELECT(Meeting.AllColumns).WHERE(PaginateTimestampConds(tsCursor)).DebugSql()
assert.Contains(t, sql, "meeting.start_time <")
}
func TestNewCursorFromJSONError(t *testing.T) {
RegisterColumn(User.ID)
_, err := NewCursorFromJSON(NewInt64Value(0, User.ID), []byte(`{invalid`))
require.Error(t, err)
}
func TestColWrongTypePanics(t *testing.T) {
RegisterColumn(User.ID)
// Key points at user.id (integer) but we construct StringValue for it.
sv := &StringValue{Key: NewColumnKey(User.ID), Val: "x"}
assert.Panics(t, func() { sv.Col() })
}
func TestQueryCount(t *testing.T) {
registerMeetingCursorColumns(t)
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
rows := sqlmock.NewRows([]string{
"QueryCountResult.Total",
"QueryCountResult.Before",
"QueryCountResult.After",
}).AddRow(10, 3, 5)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
when := mustTime("2026-06-01T09:00:00Z")
start := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
end := start.CopyWithVals(
NewStringValue("meeting-uuid-2", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
sqlo := &queryableDB{db: db}
res, err := QueryCount(sqlo, Meeting.ID, Meeting, mysql.Bool(true), start, end)
require.NoError(t, err)
assert.Equal(t, QueryCountResult{Total: 10, Before: 3, After: 5}, res)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryCountError(t *testing.T) {
registerMeetingCursorColumns(t)
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
mock.ExpectQuery("SELECT").WillReturnError(assert.AnError)
when := mustTime("2026-06-01T09:00:00Z")
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
sqlo := &queryableDB{db: db}
_, err = QueryCount(sqlo, Meeting.ID, Meeting, mysql.Bool(true), cursor, cursor)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to query count")
}
func TestBuildQueryCountFnInvoke(t *testing.T) {
registerMeetingCursorColumns(t)
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
rows := sqlmock.NewRows([]string{
"QueryCountResult.Total",
"QueryCountResult.Before",
"QueryCountResult.After",
}).AddRow(1, 0, 0)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
when := mustTime("2026-06-01T09:00:00Z")
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
fn := BuildQueryCountFn(Meeting.ID, Meeting, mysql.Bool(true))
res, err := fn(&queryableDB{db: db}, cursor, cursor)
require.NoError(t, err)
assert.Equal(t, 1, res.Total)
}
// queryableDB implements dbx.Queryable for tests.
type queryableDB struct {
db *sql.DB
}
func (q *queryableDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return q.db.QueryContext(ctx, query, args...)
}
func (q *queryableDB) Query(query string, args ...any) (*sql.Rows, error) {
return q.db.Query(query, args...)
}
+314
View File
@@ -0,0 +1,314 @@
package cursor
import (
"strings"
"testing"
"time"
"github.com/go-jet/jet/v2/mysql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTimestampValue(t *testing.T) {
assert := assert.New(t)
RegisterColumn(Meeting.StartTime)
when := time.Date(2026, 3, 15, 14, 30, 0, 0, time.UTC)
v := NewTimestampValue(when, Meeting.StartTime)
assert.Equal(when, v.Val)
assert.Equal(Meeting.StartTime, v.Col())
assert.False(v.IsEmpty())
assert.True(NewTimestampValue(time.Time{}, Meeting.StartTime).IsEmpty())
}
func TestMeetingCompositeCursor(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
base := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
cursor := base.CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
assert.Equal(NewColumnKey(Meeting.ID), cursor.Index.ColumnKey())
assert.Equal(NewColumnKey(Meeting.StartTime), cursor.OrderColumnKey)
assert.True(cursor.IsComposite())
assert.False(cursor.IsEmpty())
encoded, err := cursor.Encode()
require.NoError(t, err)
assert.Contains(encoded, `"order_val"`)
assert.Contains(encoded, `"start_time"`)
decoded := NewCursor(NewStringValue("", Meeting.ID), Meeting.StartTime, OrderDescending)
require.NoError(t, decoded.Decode(encoded))
assert.True(decoded.IsComposite())
assert.Equal("meeting-uuid-1", decoded.Index.(*StringValue).Val)
assert.Equal(when, decoded.GenericOrderValue().(*TimestampValue).Val)
}
func TestMeetingCursorWithoutOrderValueIsNotComposite(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
assert.False(cursor.IsComposite())
}
func TestMeetingCursorUsesTupleOrdering(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
assert.True(cursor.UsesTupleOrdering())
assert.False(cursor.IsComposite())
}
func TestPaginateCompositeDESC(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
stmt := mysqlSELECTWhere(PaginateConds(cursor))
sql := stmt.DebugSql()
assert.Contains(sql, "(meeting.start_time, meeting.id) < ('")
}
func TestPaginateCompositeASC(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderAscending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
stmt := mysqlSELECTWhere(PaginateConds(cursor))
sql := stmt.DebugSql()
assert.Contains(sql, "(meeting.start_time, meeting.id) > ('")
}
func TestOrderByClausesComposite(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
clauses := OrderByClauses(cursor)
require.Len(t, clauses, 2)
stmt := Meeting.SELECT(Meeting.AllColumns).ORDER_BY(clauses...)
sql := stmt.DebugSql()
assert.True(
strings.Contains(sql, "start_time DESC") && strings.Contains(sql, "id DESC"),
"expected composite order by, got: %s", sql,
)
}
func TestOrderByClausesTupleOrderingDefaultCursor(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
cursor := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
)
assert.True(cursor.UsesTupleOrdering())
assert.False(cursor.IsComposite())
clauses := OrderByClauses(cursor)
require.Len(t, clauses, 2)
stmt := Meeting.SELECT(Meeting.AllColumns).ORDER_BY(clauses...)
sql := stmt.DebugSql()
assert.True(
strings.Contains(sql, "start_time DESC") && strings.Contains(sql, "id DESC"),
"expected tuple order by on default cursor, got: %s", sql,
)
}
func TestOrderByClausesNil(t *testing.T) {
assert.Nil(t, OrderByClauses(nil))
}
func TestOrderByClausesSingleColumnASC(t *testing.T) {
RegisterColumn(User.ID)
cursor := NewCursor(NewInt64Value(1, User.ID), User.ID, OrderAscending)
clauses := OrderByClauses(cursor)
require.Len(t, clauses, 1)
stmt := User.SELECT(User.AllColumns).ORDER_BY(clauses...)
sql := stmt.DebugSql()
assert.Contains(t, sql, "id ASC")
}
func TestPaginateValueTypes(t *testing.T) {
RegisterColumn(User.ID, Meeting.StartTime)
tests := []struct {
name string
cond mysql.BoolExpression
wantSubstr string
}{
{
name: "int64 desc",
cond: PaginateConds(NewCursor(
NewInt64Value(10, User.ID),
User.ID,
OrderDescending,
)),
wantSubstr: "user.id < 10",
},
{
name: "int64 asc",
cond: PaginateConds(NewCursor(
NewInt64Value(10, User.ID),
User.ID,
OrderAscending,
)),
wantSubstr: "user.id > 10",
},
{
name: "uint64 desc",
cond: PaginateConds(NewCursor(
NewUint64Value(10, User.ID),
User.ID,
OrderDescending,
)),
wantSubstr: "user.id < 10",
},
{
name: "timestamp desc",
cond: PaginateConds(NewCursor(
NewTimestampValue(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Meeting.StartTime),
Meeting.StartTime,
OrderDescending,
)),
wantSubstr: "meeting.start_time <",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sql := User.SELECT(User.AllColumns).WHERE(tc.cond).DebugSql()
assert.Contains(t, sql, tc.wantSubstr)
})
}
}
func TestQueryCountCompositeSQL(t *testing.T) {
assert := assert.New(t)
registerMeetingCursorColumns(t)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
start := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
end := start.CopyWithVals(
NewStringValue("meeting-uuid-2", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
stmt := Meeting.SELECT(
mysql.COUNT(countBoundExpr(start, ">")).AS("before"),
mysql.COUNT(countBoundExpr(end, "<")).AS("after"),
).WHERE(mysql.Bool(true))
sql := stmt.DebugSql()
assert.Contains(sql, "IF((meeting.start_time, meeting.id) > ('")
assert.Contains(sql, "IF((meeting.start_time, meeting.id) < ('")
}
func TestCountBoundExprSimple(t *testing.T) {
RegisterColumn(MeetingRoom.ID)
cursor := NewCursor(
NewStringValue("room-1", MeetingRoom.ID),
MeetingRoom.ID,
OrderDescending,
)
stmt := MeetingRoom.SELECT(
mysql.COUNT(countBoundExpr(cursor, "<")).AS("after"),
).WHERE(mysql.Bool(true))
sql := stmt.DebugSql()
assert.Contains(t, sql, "IF(meeting_room.id < 'room-1', 1, NULL)")
}
func TestBuildQueryCountFn(t *testing.T) {
registerMeetingCursorColumns(t)
fn := BuildQueryCountFn(Meeting.ID, Meeting, mysql.Bool(true))
require.NotNil(t, fn)
when := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
start := NewCursor(
NewStringValue("", Meeting.ID),
Meeting.StartTime,
OrderDescending,
).CopyWithVals(
NewStringValue("meeting-uuid-1", Meeting.ID),
NewTimestampValue(when, Meeting.StartTime),
)
// Verify the returned fn builds the same SQL shape as QueryCount would.
stmt := Meeting.SELECT(
mysql.COUNT(Meeting.ID).AS("QueryCountResult.Total"),
mysql.COUNT(countBoundExpr(start, ">")).AS("QueryCountResult.Before"),
mysql.COUNT(countBoundExpr(start, "<")).AS("QueryCountResult.After"),
).WHERE(mysql.Bool(true))
sql := stmt.DebugSql()
assert.Contains(t, sql, "COUNT(meeting.id)")
assert.Contains(t, sql, "IF((meeting.start_time, meeting.id) >")
}
+13
View File
@@ -0,0 +1,13 @@
package cursor
// DereferenceSlice takes a slice of pointers to T and returns a slice of T by
// dereferencing each pointer and discarding any nil pointers.
func DereferenceSlice[T any](list []*T) []T {
var result []T
for _, item := range list {
if item != nil {
result = append(result, *item)
}
}
return result
}
+15
View File
@@ -0,0 +1,15 @@
package cursor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDereferenceSlice(t *testing.T) {
a, b := 1, 2
result := DereferenceSlice([]*int{&a, nil, &b})
assert.Equal(t, []int{1, 2}, result)
assert.Empty(t, DereferenceSlice([]*int{}))
assert.Empty(t, DereferenceSlice[int](nil))
}