docs: add package godoc and clarify public API
Prepare the extracted library for external consumption with grouped godoc, extension point docs, and setup requirements. Wire ErrBadCursorString into decode paths and drop redundant Paginate*Conds aliases. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -138,6 +138,14 @@ edgeCursor := base.CopyWithVals(
|
|||||||
| `string` / UUID | `NewStringValue` |
|
| `string` / UUID | `NewStringValue` |
|
||||||
| `time.Time` | `NewTimestampValue` |
|
| `time.Time` | `NewTimestampValue` |
|
||||||
|
|
||||||
|
## Extension points
|
||||||
|
|
||||||
|
Custom cursor value types implement [GenericExpr] (order values) and [ExprMarshaler] (index values). See package godoc for requirements.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Decode failures return `ErrBadCursorString`; use `errors.Is` to detect invalid client cursors.
|
||||||
|
|
||||||
## Utilities
|
## Utilities
|
||||||
|
|
||||||
- `ExtractNodes` — nodes from a `Connection`
|
- `ExtractNodes` — nodes from a `Connection`
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrBadCursor is returned when a cursor is invalid.
|
// ErrBadCursorString is returned when a cursor string cannot be decoded.
|
||||||
ErrBadCursorString = errors.New("bad cursor string")
|
ErrBadCursorString = errors.New("bad cursor string")
|
||||||
|
|
||||||
columnRegistry = make(map[ColumnKey]mysql.Column)
|
columnRegistry = make(map[ColumnKey]mysql.Column)
|
||||||
@@ -52,6 +52,9 @@ func (k ColumnKey) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterColumn registers one or more columns with the cursor registry.
|
// RegisterColumn registers one or more columns with the cursor registry.
|
||||||
|
//
|
||||||
|
// Required at application startup before encoding, decoding, or SQL generation.
|
||||||
|
// Every index column, order column, and OrderValue column must be registered.
|
||||||
func RegisterColumn(columns ...mysql.Column) {
|
func RegisterColumn(columns ...mysql.Column) {
|
||||||
columnRegistryLock.Lock()
|
columnRegistryLock.Lock()
|
||||||
defer columnRegistryLock.Unlock()
|
defer columnRegistryLock.Unlock()
|
||||||
@@ -61,6 +64,7 @@ func RegisterColumn(columns ...mysql.Column) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterColumnList registers a list of columns with the cursor registry.
|
// RegisterColumnList registers a list of columns with the cursor registry.
|
||||||
|
// See [RegisterColumn] for setup requirements.
|
||||||
func RegisterColumnList(columns mysql.ColumnList) {
|
func RegisterColumnList(columns mysql.ColumnList) {
|
||||||
RegisterColumn([]mysql.Column(columns)...)
|
RegisterColumn([]mysql.Column(columns)...)
|
||||||
}
|
}
|
||||||
@@ -142,13 +146,18 @@ var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Int64Value
|
|||||||
var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Uint64Value)(nil)
|
var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Uint64Value)(nil)
|
||||||
var _ ExprMarshaler[mysql.TimestampExpression, mysql.ColumnTimestamp] = (*TimestampValue)(nil)
|
var _ ExprMarshaler[mysql.TimestampExpression, mysql.ColumnTimestamp] = (*TimestampValue)(nil)
|
||||||
|
|
||||||
|
// ExprMarshaler is the typed index-value extension point. Implement GenericExpr plus
|
||||||
|
// Expr and Col to produce go-jet expressions. Col panics when the column is not
|
||||||
|
// registered via [RegisterColumn].
|
||||||
type ExprMarshaler[E mysql.Expression, C mysql.Column] interface {
|
type ExprMarshaler[E mysql.Expression, C mysql.Column] interface {
|
||||||
GenericExpr
|
GenericExpr
|
||||||
Expr() E
|
Expr() E
|
||||||
Col() C
|
Col() C
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenericExpr is the untyped cursor value contract shared by index and order values.
|
// GenericExpr is the cursor value extension point shared by index and order columns.
|
||||||
|
// Implement ColumnKey, IsEmpty, and driver.Valuer. Order values decoded from cursor
|
||||||
|
// JSON must match a registered go-jet column kind; see package documentation.
|
||||||
type GenericExpr interface {
|
type GenericExpr interface {
|
||||||
ColumnKey() ColumnKey
|
ColumnKey() ColumnKey
|
||||||
IsEmpty() bool
|
IsEmpty() bool
|
||||||
@@ -165,6 +174,8 @@ type StringValue struct {
|
|||||||
Val string `json:"val"`
|
Val string `json:"val"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewStringValue creates a cursor value for a string column (including UUIDs).
|
||||||
|
// The column must be registered via [RegisterColumn] before Col or decode use it.
|
||||||
func NewStringValue(val string, col mysql.ColumnString) *StringValue {
|
func NewStringValue(val string, col mysql.ColumnString) *StringValue {
|
||||||
return &StringValue{
|
return &StringValue{
|
||||||
Key: NewColumnKey(col),
|
Key: NewColumnKey(col),
|
||||||
@@ -201,6 +212,8 @@ type Int64Value struct {
|
|||||||
Val int64 `json:"val"`
|
Val int64 `json:"val"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewInt64Value creates a cursor value for a signed integer column.
|
||||||
|
// The column must be registered via [RegisterColumn] before Col or decode use it.
|
||||||
func NewInt64Value(val int64, col mysql.ColumnInteger) *Int64Value {
|
func NewInt64Value(val int64, col mysql.ColumnInteger) *Int64Value {
|
||||||
return &Int64Value{
|
return &Int64Value{
|
||||||
Key: NewColumnKey(col),
|
Key: NewColumnKey(col),
|
||||||
@@ -237,6 +250,8 @@ type Uint64Value struct {
|
|||||||
Val uint64 `json:"val"`
|
Val uint64 `json:"val"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewUint64Value creates a cursor value for an unsigned integer column.
|
||||||
|
// The column must be registered via [RegisterColumn] before Col or decode use it.
|
||||||
func NewUint64Value(val uint64, col mysql.ColumnInteger) *Uint64Value {
|
func NewUint64Value(val uint64, col mysql.ColumnInteger) *Uint64Value {
|
||||||
return &Uint64Value{
|
return &Uint64Value{
|
||||||
Key: NewColumnKey(col),
|
Key: NewColumnKey(col),
|
||||||
@@ -275,6 +290,7 @@ type TimestampValue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewTimestampValue creates a timestamp cursor value for the given column.
|
// NewTimestampValue creates a timestamp cursor value for the given column.
|
||||||
|
// The column must be registered via [RegisterColumn] before Col or decode use it.
|
||||||
func NewTimestampValue(val time.Time, col mysql.ColumnTimestamp) *TimestampValue {
|
func NewTimestampValue(val time.Time, col mysql.ColumnTimestamp) *TimestampValue {
|
||||||
return &TimestampValue{
|
return &TimestampValue{
|
||||||
Key: NewColumnKey(col),
|
Key: NewColumnKey(col),
|
||||||
@@ -310,9 +326,9 @@ func (t *TimestampValue) IsEmpty() bool {
|
|||||||
return t == nil || t.Key.Table == "" || t.Key.Column == "" || t.Val.IsZero()
|
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
|
// GenericCursor is the cursor extension point used by edge building, page counting,
|
||||||
// expression and column. Only the methods that do not depend on the specific
|
// and pagination helpers without concrete type parameters. [Cursor] implements this
|
||||||
// types of expressions and columns are defined here.
|
// interface.
|
||||||
type GenericCursor interface {
|
type GenericCursor interface {
|
||||||
IsEmpty() bool
|
IsEmpty() bool
|
||||||
IsComposite() bool
|
IsComposite() bool
|
||||||
@@ -365,7 +381,7 @@ func NewCursor[IE mysql.Expression, IC mysql.Column](index ExprMarshaler[IE, IC]
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewCursorFromAfterPtr decodes a Relay-style after cursor. Nil or empty after
|
// NewCursorFromAfterPtr decodes a Relay-style after cursor. Nil or empty after
|
||||||
// returns (nil, nil).
|
// returns (nil, nil). Returns [ErrBadCursorString] when after is not valid cursor JSON.
|
||||||
func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
|
func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
|
||||||
newZero func() *Cursor[IE, IC],
|
newZero func() *Cursor[IE, IC],
|
||||||
after *string,
|
after *string,
|
||||||
@@ -378,6 +394,7 @@ func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewCursorFromJSON returns a Cursor from a JSON representation.
|
// NewCursorFromJSON returns a Cursor from a JSON representation.
|
||||||
|
// Returns [ErrBadCursorString] when src is not valid cursor JSON.
|
||||||
func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarshaler[IE, IC], src []byte) (*Cursor[IE, IC], error) {
|
func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarshaler[IE, IC], src []byte) (*Cursor[IE, IC], error) {
|
||||||
cursor := Cursor[IE, IC]{
|
cursor := Cursor[IE, IC]{
|
||||||
Index: zeroIndex,
|
Index: zeroIndex,
|
||||||
@@ -389,13 +406,15 @@ func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarsh
|
|||||||
return &cursor, nil
|
return &cursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyWithVal returns a new cursor with the specified value and the current ordering.
|
// CopyWithVal returns a new cursor with the specified index value and the current
|
||||||
|
// ordering. Use for simple pagination where the index and order column are the same.
|
||||||
func (c *Cursor[IE, IC]) CopyWithVal(val ExprMarshaler[IE, IC]) *Cursor[IE, IC] {
|
func (c *Cursor[IE, IC]) CopyWithVal(val ExprMarshaler[IE, IC]) *Cursor[IE, IC] {
|
||||||
return NewCursor(val, c.OrderCol(), c.OrderDir)
|
return NewCursor(val, c.OrderCol(), c.OrderDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyWithVals returns a new cursor with the specified index and order values.
|
// CopyWithVals returns a new cursor with the specified index and order values.
|
||||||
// Use this when IsComposite() is true.
|
// Use when building edge cursors under tuple ordering: set both values so
|
||||||
|
// [IsComposite] is true and after pagination compares (order_col, index_col).
|
||||||
func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal GenericExpr) *Cursor[IE, IC] {
|
func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal GenericExpr) *Cursor[IE, IC] {
|
||||||
result := NewCursor(index, c.OrderCol(), c.OrderDir)
|
result := NewCursor(index, c.OrderCol(), c.OrderDir)
|
||||||
result.OrderValue = orderVal
|
result.OrderValue = orderVal
|
||||||
@@ -403,8 +422,10 @@ func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal Gene
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UsesTupleOrdering reports whether results are sorted by (order_col, index_col).
|
// 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.
|
// True when the order column differs from the index column. Applies to default
|
||||||
|
// cursors on the first page and drives [OrderByClauses]. Unlike [IsComposite],
|
||||||
|
// OrderValue is not required.
|
||||||
func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
|
func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
|
||||||
if c == nil || c.Index == nil {
|
if c == nil || c.Index == nil {
|
||||||
return false
|
return false
|
||||||
@@ -416,7 +437,11 @@ func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
|
|||||||
return c.OrderColumnKey != indexKey
|
return c.OrderColumnKey != indexKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsComposite reports whether pagination uses (order_col, index_col) tuple comparison.
|
// IsComposite reports whether after-pagination filters on (order_col, index_col).
|
||||||
|
//
|
||||||
|
// True when tuple ordering is active and OrderValue is set—typical for encoded edge
|
||||||
|
// cursors when the sort column is non-unique. Drives [PaginateConds] and page
|
||||||
|
// counting via lexicographic tuple comparison.
|
||||||
func (c *Cursor[IE, IC]) IsComposite() bool {
|
func (c *Cursor[IE, IC]) IsComposite() bool {
|
||||||
if c == nil || c.Index == nil || c.Index.IsEmpty() {
|
if c == nil || c.Index == nil || c.Index.IsEmpty() {
|
||||||
return false
|
return false
|
||||||
@@ -471,6 +496,7 @@ func (c *Cursor[IE, IC]) Encode() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode decodes a stringified JSON representation of the cursor into this object.
|
// Decode decodes a stringified JSON representation of the cursor into this object.
|
||||||
|
// Returns [ErrBadCursorString] when the input is not valid cursor JSON.
|
||||||
func (c *Cursor[IE, IC]) Decode(src string) error {
|
func (c *Cursor[IE, IC]) Decode(src string) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("cursor is nil")
|
return fmt.Errorf("cursor is nil")
|
||||||
@@ -478,8 +504,8 @@ func (c *Cursor[IE, IC]) Decode(src string) error {
|
|||||||
return decodeCursorJSON(c, []byte(src), false, OrderAscending)
|
return decodeCursorJSON(c, []byte(src), false, OrderAscending)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecodeAndOrder decodes a stringified JSON representation of the cursor into
|
// DecodeAndOrder decodes a cursor string like [Cursor.Decode] but replaces the
|
||||||
// this object and applies a new order direction.
|
// encoded order direction with orderDir.
|
||||||
func (c *Cursor[IE, IC]) DecodeAndOrder(src string, orderDir OrderDirection) error {
|
func (c *Cursor[IE, IC]) DecodeAndOrder(src string, orderDir OrderDirection) error {
|
||||||
return decodeCursorJSON(c, []byte(src), true, orderDir)
|
return decodeCursorJSON(c, []byte(src), true, orderDir)
|
||||||
}
|
}
|
||||||
@@ -492,11 +518,11 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
|
|||||||
) error {
|
) error {
|
||||||
var raw cursorJSON
|
var raw cursorJSON
|
||||||
if err := json.Unmarshal(src, &raw); err != nil {
|
if err := json.Unmarshal(src, &raw); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal cursor: %w", err)
|
return fmt.Errorf("%w: invalid JSON: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(raw.Index, c.Index); err != nil {
|
if err := json.Unmarshal(raw.Index, c.Index); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal cursor index: %w", err)
|
return fmt.Errorf("%w: invalid index: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.OrderColumnKey = raw.OrderColumnKey
|
c.OrderColumnKey = raw.OrderColumnKey
|
||||||
@@ -508,7 +534,7 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
|
|||||||
if len(raw.OrderValue) > 0 {
|
if len(raw.OrderValue) > 0 {
|
||||||
orderVal, err := unmarshalGenericExpr(raw.OrderValue, raw.OrderColumnKey)
|
orderVal, err := unmarshalGenericExpr(raw.OrderValue, raw.OrderColumnKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal cursor order value: %w", err)
|
return fmt.Errorf("%w: invalid order value: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
c.OrderValue = orderVal
|
c.OrderValue = orderVal
|
||||||
} else {
|
} else {
|
||||||
@@ -521,30 +547,30 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
|
|||||||
func unmarshalGenericExpr(data []byte, key ColumnKey) (GenericExpr, error) {
|
func unmarshalGenericExpr(data []byte, key ColumnKey) (GenericExpr, error) {
|
||||||
col, err := GetColumnByKey(key)
|
col, err := GetColumnByKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch col.(type) {
|
switch col.(type) {
|
||||||
case mysql.ColumnTimestamp:
|
case mysql.ColumnTimestamp:
|
||||||
var v TimestampValue
|
var v TimestampValue
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
return &v, nil
|
return &v, nil
|
||||||
case mysql.ColumnInteger:
|
case mysql.ColumnInteger:
|
||||||
var v Uint64Value
|
var v Uint64Value
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
return &v, nil
|
return &v, nil
|
||||||
case mysql.ColumnString:
|
case mysql.ColumnString:
|
||||||
var v StringValue
|
var v StringValue
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
|
||||||
}
|
}
|
||||||
return &v, nil
|
return &v, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported order column type for %s", key)
|
return nil, fmt.Errorf("%w: unsupported order column type for %s", ErrBadCursorString, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Package cursor implements Relay-style cursor pagination for go-jet MySQL queries.
|
||||||
|
//
|
||||||
|
// It encodes opaque cursor strings from column values, generates matching WHERE and
|
||||||
|
// ORDER BY clauses, and builds GraphQL Relay–compatible Connection results with
|
||||||
|
// PageInfo and total counts.
|
||||||
|
//
|
||||||
|
// # Requirements
|
||||||
|
//
|
||||||
|
// - [github.com/go-jet/jet/v2/mysql] generated table and column types
|
||||||
|
// - [gitea.auvem.com/go-toolkit/dbx.Queryable] for running queries (optional when
|
||||||
|
// building SQL manually)
|
||||||
|
//
|
||||||
|
// # Setup
|
||||||
|
//
|
||||||
|
// Call [RegisterColumn] (or [RegisterColumnList]) at application startup for every
|
||||||
|
// column referenced by a cursor—index column, order column, and any column held in
|
||||||
|
// OrderValue. The registry resolves columns when encoding, decoding, and building SQL.
|
||||||
|
// [Cursor.OrderCol], value Col methods, and [GetColumnByKey] panic when a column is
|
||||||
|
// not registered.
|
||||||
|
//
|
||||||
|
// # Pagination modes
|
||||||
|
//
|
||||||
|
// Simple pagination sorts and filters on a single column (typically the primary key).
|
||||||
|
// When the order column differs from the index column, tuple ordering applies:
|
||||||
|
// results sort by (order_col, index_col). See [Cursor.UsesTupleOrdering].
|
||||||
|
//
|
||||||
|
// Composite cursors extend tuple ordering with an encoded OrderValue on each edge.
|
||||||
|
// Required for correct after pagination when sort values can repeat (for example,
|
||||||
|
// many rows sharing the same timestamp). See [Cursor.IsComposite] and [CopyWithVals].
|
||||||
|
//
|
||||||
|
// # API overview
|
||||||
|
//
|
||||||
|
// Setup — [RegisterColumn], [RegisterColumnList], [GetColumn], [GetColumnByKey]
|
||||||
|
//
|
||||||
|
// Cursor construction — [NewCursor], [NewInt64Value], [NewUint64Value],
|
||||||
|
// [NewStringValue], [NewTimestampValue], [CopyWithVal], [CopyWithVals]
|
||||||
|
//
|
||||||
|
// Serialization — [Cursor.Encode], [Cursor.Decode], [Cursor.DecodeAndOrder],
|
||||||
|
// [NewCursorFromAfterPtr], [NewCursorFromJSON]
|
||||||
|
//
|
||||||
|
// Query integration — [PaginateConds], [OrderByClauses], [QueryCount], [BuildQueryCountFn]
|
||||||
|
//
|
||||||
|
// Relay layer — [PageQuery], [BuildEdges], [ConnectionFromRelayArgs], [Connection],
|
||||||
|
// [PageInfo], [Edge]
|
||||||
|
//
|
||||||
|
// Utilities — [ExtractNodes], [DereferenceSlice]
|
||||||
|
//
|
||||||
|
// # Extension points
|
||||||
|
//
|
||||||
|
// Consumers may define custom cursor value types by implementing the interfaces below.
|
||||||
|
// Built-in types ([Int64Value], [StringValue], and others) demonstrate the pattern.
|
||||||
|
//
|
||||||
|
// [GenericExpr] is the untyped value contract shared by index and order columns.
|
||||||
|
// Implement ColumnKey, IsEmpty, and driver.Valuer. Use JSON struct tags matching
|
||||||
|
// the built-in value types when values are encoded inside a cursor.
|
||||||
|
//
|
||||||
|
// [ExprMarshaler] extends GenericExpr for index columns. Implement Expr and Col to
|
||||||
|
// produce type-safe go-jet expressions. Col requires the column to be registered.
|
||||||
|
// Custom index types that are not handled by the built-in type switch in pagination
|
||||||
|
// fall back to raw SQL comparisons; registering the column is still required.
|
||||||
|
//
|
||||||
|
// [GenericCursor] is implemented by [Cursor] and is the interface used when building
|
||||||
|
// edges, counting pages, and applying pagination conditions without concrete type
|
||||||
|
// parameters.
|
||||||
|
//
|
||||||
|
// OrderValue decoding selects a built-in wrapper type from the registered go-jet
|
||||||
|
// column type (string, integer, timestamp). Custom order values should use one of
|
||||||
|
// the built-in value types or match their JSON shape for the corresponding column
|
||||||
|
// kind.
|
||||||
|
package cursor
|
||||||
@@ -22,8 +22,7 @@ type PageInfo struct {
|
|||||||
StartCursor *string `json:"startCursor,omitempty"`
|
StartCursor *string `json:"startCursor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CursorFunc is a function that takes a pointer to an object type T, the
|
// CursorFunc builds a [GenericCursor] for a connection node.
|
||||||
// underlying database view object, and returns a GenericCursor or an error.
|
|
||||||
type CursorFunc[T any] = func(*T) (GenericCursor, error)
|
type CursorFunc[T any] = func(*T) (GenericCursor, error)
|
||||||
|
|
||||||
// Edge represents a single edge in a connection, containing a node of type T
|
// Edge represents a single edge in a connection, containing a node of type T
|
||||||
@@ -85,7 +84,7 @@ func BuildEdges[T any](
|
|||||||
pageInfo.EndCursor = &edges[len(edges)-1].Cursor
|
pageInfo.EndCursor = &edges[len(edges)-1].Cursor
|
||||||
|
|
||||||
// Fetch total count. Comparisons use composite tuple logic when the cursor
|
// Fetch total count. Comparisons use composite tuple logic when the cursor
|
||||||
// encodes both order and index columns (see meeting pagination).
|
// encodes both order and index columns.
|
||||||
startCursor, err := cursorFunc(edges[0].Node)
|
startCursor, err := cursorFunc(edges[0].Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get start cursor: %w", err)
|
return nil, fmt.Errorf("failed to get start cursor: %w", err)
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ func (q PageQuery[T, IE, IC]) Run() (*Connection[T], error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionFromRelayArgs parses Relay pagination args and calls list with the
|
// ConnectionFromRelayArgs parses Relay after/first args and calls list with the
|
||||||
// decoded cursor and limit. newZero supplies the cursor template for decoding
|
// decoded cursor and limit. newZero returns a template cursor used to decode after
|
||||||
// (same as NewXCursor).
|
// (same shape as the cursor factory for the resource, e.g. newUserCursor).
|
||||||
func ConnectionFromRelayArgs[T any, IE mysql.Expression, IC mysql.Column](
|
func ConnectionFromRelayArgs[T any, IE mysql.Expression, IC mysql.Column](
|
||||||
after *string,
|
after *string,
|
||||||
first *int,
|
first *int,
|
||||||
|
|||||||
+3
-2
@@ -60,6 +60,7 @@ func TestConnectionFromRelayArgs(t *testing.T) {
|
|||||||
bad := "{not-json"
|
bad := "{not-json"
|
||||||
_, err = ConnectionFromRelayArgs(&bad, nil, newZero, list)
|
_, err = ConnectionFromRelayArgs(&bad, nil, newZero, list)
|
||||||
require.Error(err)
|
require.Error(err)
|
||||||
|
require.ErrorIs(err, ErrBadCursorString)
|
||||||
|
|
||||||
listErr := errors.New("list failed")
|
listErr := errors.New("list failed")
|
||||||
_, err = ConnectionFromRelayArgs(nil, nil, newZero, func(*Cursor[mysql.IntegerExpression, mysql.ColumnInteger], int) (*Connection[int], error) {
|
_, err = ConnectionFromRelayArgs(nil, nil, newZero, func(*Cursor[mysql.IntegerExpression, mysql.ColumnInteger], int) (*Connection[int], error) {
|
||||||
@@ -105,7 +106,7 @@ func TestPageQueryRunSQLSimple(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
|
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
|
||||||
stmt.WHERE(mysql.Bool(true).AND(PaginateStringConds(active)))
|
stmt.WHERE(mysql.Bool(true).AND(PaginateConds(active)))
|
||||||
stmt.ORDER_BY(OrderByClauses(active)...)
|
stmt.ORDER_BY(OrderByClauses(active)...)
|
||||||
sql := stmt.DebugSql()
|
sql := stmt.DebugSql()
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ func TestPageQueryRunSQLFirstPageRoomDefaultCursor(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
|
stmt := mysql.SELECT(MeetingRoom.AllColumns).FROM(MeetingRoom)
|
||||||
stmt.WHERE(mysql.Bool(true).AND(PaginateStringConds(defaultCursor)))
|
stmt.WHERE(mysql.Bool(true).AND(PaginateConds(defaultCursor)))
|
||||||
stmt.ORDER_BY(OrderByClauses(defaultCursor)...)
|
stmt.ORDER_BY(OrderByClauses(defaultCursor)...)
|
||||||
sql := stmt.DebugSql()
|
sql := stmt.DebugSql()
|
||||||
|
|
||||||
|
|||||||
@@ -107,35 +107,11 @@ func BuildQueryCountFn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PaginateConds returns a mysql.BoolExpression that paginates results using the
|
// PaginateConds returns a mysql.BoolExpression that paginates results using the
|
||||||
// provided cursor as a base position.
|
// provided cursor as a base position. Nil or empty cursors match all rows.
|
||||||
func PaginateConds[IE mysql.Expression, IC mysql.Column](c *Cursor[IE, IC]) mysql.BoolExpression {
|
func PaginateConds[IE mysql.Expression, IC mysql.Column](c *Cursor[IE, IC]) mysql.BoolExpression {
|
||||||
return paginateFromGeneric(c)
|
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 {
|
func paginateFromGeneric(c GenericCursor) mysql.BoolExpression {
|
||||||
if c == nil || c.IsEmpty() {
|
if c == nil || c.IsEmpty() {
|
||||||
return mysql.Bool(true)
|
return mysql.Bool(true)
|
||||||
@@ -201,7 +177,8 @@ func paginateComposite(c GenericCursor) mysql.BoolExpression {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderByClauses returns ORDER BY clauses matching the cursor pagination semantics.
|
// OrderByClauses returns ORDER BY clauses matching the cursor pagination semantics,
|
||||||
|
// including tuple ordering when [GenericCursor.UsesTupleOrdering] is true.
|
||||||
func OrderByClauses(c GenericCursor) []mysql.OrderByClause {
|
func OrderByClauses(c GenericCursor) []mysql.OrderByClause {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+5
-4
@@ -38,16 +38,16 @@ func TestUsesTupleOrderingEdgeCases(t *testing.T) {
|
|||||||
assert.False(t, sameCol.UsesTupleOrdering())
|
assert.False(t, sameCol.UsesTupleOrdering())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPaginateWrapperFuncs(t *testing.T) {
|
func TestPaginateCondsValueTypes(t *testing.T) {
|
||||||
RegisterColumn(User.ID, Meeting.StartTime)
|
RegisterColumn(User.ID, Meeting.StartTime)
|
||||||
|
|
||||||
intCursor := NewCursor(NewInt64Value(1, User.ID), User.ID, OrderDescending)
|
intCursor := NewCursor(NewInt64Value(1, User.ID), User.ID, OrderDescending)
|
||||||
sql := User.SELECT(User.AllColumns).WHERE(PaginateIntConds(intCursor)).DebugSql()
|
sql := User.SELECT(User.AllColumns).WHERE(PaginateConds(intCursor)).DebugSql()
|
||||||
assert.Contains(t, sql, "user.id < 1")
|
assert.Contains(t, sql, "user.id < 1")
|
||||||
|
|
||||||
strCursor := NewCursor(NewStringValue("x", MeetingRoom.ID), MeetingRoom.ID, OrderDescending)
|
strCursor := NewCursor(NewStringValue("x", MeetingRoom.ID), MeetingRoom.ID, OrderDescending)
|
||||||
RegisterColumn(MeetingRoom.ID)
|
RegisterColumn(MeetingRoom.ID)
|
||||||
sql = MeetingRoom.SELECT(MeetingRoom.AllColumns).WHERE(PaginateUUIDConds(strCursor)).DebugSql()
|
sql = MeetingRoom.SELECT(MeetingRoom.AllColumns).WHERE(PaginateConds(strCursor)).DebugSql()
|
||||||
assert.Contains(t, sql, "meeting_room.id < 'x'")
|
assert.Contains(t, sql, "meeting_room.id < 'x'")
|
||||||
|
|
||||||
tsCursor := NewCursor(
|
tsCursor := NewCursor(
|
||||||
@@ -55,7 +55,7 @@ func TestPaginateWrapperFuncs(t *testing.T) {
|
|||||||
Meeting.StartTime,
|
Meeting.StartTime,
|
||||||
OrderDescending,
|
OrderDescending,
|
||||||
)
|
)
|
||||||
sql = Meeting.SELECT(Meeting.AllColumns).WHERE(PaginateTimestampConds(tsCursor)).DebugSql()
|
sql = Meeting.SELECT(Meeting.AllColumns).WHERE(PaginateConds(tsCursor)).DebugSql()
|
||||||
assert.Contains(t, sql, "meeting.start_time <")
|
assert.Contains(t, sql, "meeting.start_time <")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ func TestNewCursorFromJSONError(t *testing.T) {
|
|||||||
RegisterColumn(User.ID)
|
RegisterColumn(User.ID)
|
||||||
_, err := NewCursorFromJSON(NewInt64Value(0, User.ID), []byte(`{invalid`))
|
_, err := NewCursorFromJSON(NewInt64Value(0, User.ID), []byte(`{invalid`))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, ErrBadCursorString)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestColWrongTypePanics(t *testing.T) {
|
func TestColWrongTypePanics(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user