import files, add README & LICENSE
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
coverage.out
|
||||||
@@ -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.
|
||||||
@@ -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 Relay–compatible `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
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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
@@ -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) >")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user