diff --git a/README.md b/README.md index 525092f..fd9e7d2 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,14 @@ edgeCursor := base.CopyWithVals( | `string` / UUID | `NewStringValue` | | `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 - `ExtractNodes` — nodes from a `Connection` diff --git a/cursor.go b/cursor.go index de749eb..d304a04 100644 --- a/cursor.go +++ b/cursor.go @@ -13,7 +13,7 @@ import ( ) 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") 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. +// +// 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) { columnRegistryLock.Lock() defer columnRegistryLock.Unlock() @@ -61,6 +64,7 @@ func RegisterColumn(columns ...mysql.Column) { } // RegisterColumnList registers a list of columns with the cursor registry. +// See [RegisterColumn] for setup requirements. func RegisterColumnList(columns mysql.ColumnList) { 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.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 { GenericExpr Expr() E 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 { ColumnKey() ColumnKey IsEmpty() bool @@ -165,6 +174,8 @@ type StringValue struct { 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 { return &StringValue{ Key: NewColumnKey(col), @@ -201,6 +212,8 @@ type Int64Value struct { 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 { return &Int64Value{ Key: NewColumnKey(col), @@ -237,6 +250,8 @@ type Uint64Value struct { 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 { return &Uint64Value{ Key: NewColumnKey(col), @@ -275,6 +290,7 @@ type TimestampValue struct { } // 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 { return &TimestampValue{ Key: NewColumnKey(col), @@ -310,9 +326,9 @@ 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. +// GenericCursor is the cursor extension point used by edge building, page counting, +// and pagination helpers without concrete type parameters. [Cursor] implements this +// interface. type GenericCursor interface { IsEmpty() 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 -// returns (nil, nil). +// returns (nil, nil). Returns [ErrBadCursorString] when after is not valid cursor JSON. func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column]( newZero func() *Cursor[IE, IC], after *string, @@ -378,6 +394,7 @@ func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column]( } // 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) { cursor := Cursor[IE, IC]{ Index: zeroIndex, @@ -389,13 +406,15 @@ func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarsh 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] { 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. +// 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] { result := NewCursor(index, c.OrderCol(), c.OrderDir) 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). -// 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 { if c == nil || c.Index == nil { return false @@ -416,7 +437,11 @@ func (c *Cursor[IE, IC]) UsesTupleOrdering() bool { 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 { if c == nil || c.Index == nil || c.Index.IsEmpty() { 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. +// Returns [ErrBadCursorString] when the input is not valid cursor JSON. func (c *Cursor[IE, IC]) Decode(src string) error { if c == 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) } -// DecodeAndOrder decodes a stringified JSON representation of the cursor into -// this object and applies a new order direction. +// DecodeAndOrder decodes a cursor string like [Cursor.Decode] but replaces the +// encoded order direction with orderDir. func (c *Cursor[IE, IC]) DecodeAndOrder(src string, orderDir OrderDirection) error { return decodeCursorJSON(c, []byte(src), true, orderDir) } @@ -492,11 +518,11 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column]( ) error { var raw cursorJSON 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 { - return fmt.Errorf("failed to unmarshal cursor index: %w", err) + return fmt.Errorf("%w: invalid index: %v", ErrBadCursorString, err) } c.OrderColumnKey = raw.OrderColumnKey @@ -508,7 +534,7 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column]( 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) + return fmt.Errorf("%w: invalid order value: %v", ErrBadCursorString, err) } c.OrderValue = orderVal } else { @@ -521,30 +547,30 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column]( func unmarshalGenericExpr(data []byte, key ColumnKey) (GenericExpr, error) { col, err := GetColumnByKey(key) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err) } switch col.(type) { case mysql.ColumnTimestamp: var v TimestampValue if err := json.Unmarshal(data, &v); err != nil { - return nil, err + return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err) } return &v, nil case mysql.ColumnInteger: var v Uint64Value if err := json.Unmarshal(data, &v); err != nil { - return nil, err + return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err) } return &v, nil case mysql.ColumnString: var v StringValue if err := json.Unmarshal(data, &v); err != nil { - return nil, err + return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err) } return &v, nil 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) } } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..3c20abe --- /dev/null +++ b/doc.go @@ -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 diff --git a/edge.go b/edge.go index 2f88ee5..19b501f 100644 --- a/edge.go +++ b/edge.go @@ -22,8 +22,7 @@ type PageInfo struct { 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. +// CursorFunc builds a [GenericCursor] for a connection node. type CursorFunc[T any] = func(*T) (GenericCursor, error) // 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 // 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) if err != nil { return nil, fmt.Errorf("failed to get start cursor: %w", err) diff --git a/list.go b/list.go index bbe95fd..9339caf 100644 --- a/list.go +++ b/list.go @@ -61,9 +61,9 @@ func (q PageQuery[T, IE, IC]) Run() (*Connection[T], error) { }) } -// ConnectionFromRelayArgs parses Relay pagination args and calls list with the -// decoded cursor and limit. newZero supplies the cursor template for decoding -// (same as NewXCursor). +// ConnectionFromRelayArgs parses Relay after/first args and calls list with the +// decoded cursor and limit. newZero returns a template cursor used to decode after +// (same shape as the cursor factory for the resource, e.g. newUserCursor). func ConnectionFromRelayArgs[T any, IE mysql.Expression, IC mysql.Column]( after *string, first *int, diff --git a/list_test.go b/list_test.go index b4273e9..22f3e61 100644 --- a/list_test.go +++ b/list_test.go @@ -60,6 +60,7 @@ func TestConnectionFromRelayArgs(t *testing.T) { bad := "{not-json" _, err = ConnectionFromRelayArgs(&bad, nil, newZero, list) require.Error(err) + require.ErrorIs(err, ErrBadCursorString) listErr := errors.New("list failed") _, 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.WHERE(mysql.Bool(true).AND(PaginateStringConds(active))) + stmt.WHERE(mysql.Bool(true).AND(PaginateConds(active))) stmt.ORDER_BY(OrderByClauses(active)...) sql := stmt.DebugSql() @@ -165,7 +166,7 @@ func TestPageQueryRunSQLFirstPageRoomDefaultCursor(t *testing.T) { ) 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)...) sql := stmt.DebugSql() diff --git a/query.go b/query.go index 766c641..6de90b7 100644 --- a/query.go +++ b/query.go @@ -107,35 +107,11 @@ func BuildQueryCountFn( } // 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 { 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) @@ -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 { if c == nil { return nil diff --git a/query_count_test.go b/query_count_test.go index 047400d..648cdb8 100644 --- a/query_count_test.go +++ b/query_count_test.go @@ -38,16 +38,16 @@ func TestUsesTupleOrderingEdgeCases(t *testing.T) { assert.False(t, sameCol.UsesTupleOrdering()) } -func TestPaginateWrapperFuncs(t *testing.T) { +func TestPaginateCondsValueTypes(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() + sql := User.SELECT(User.AllColumns).WHERE(PaginateConds(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() + sql = MeetingRoom.SELECT(MeetingRoom.AllColumns).WHERE(PaginateConds(strCursor)).DebugSql() assert.Contains(t, sql, "meeting_room.id < 'x'") tsCursor := NewCursor( @@ -55,7 +55,7 @@ func TestPaginateWrapperFuncs(t *testing.T) { Meeting.StartTime, 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 <") } @@ -63,6 +63,7 @@ func TestNewCursorFromJSONError(t *testing.T) { RegisterColumn(User.ID) _, err := NewCursorFromJSON(NewInt64Value(0, User.ID), []byte(`{invalid`)) require.Error(t, err) + require.ErrorIs(t, err, ErrBadCursorString) } func TestColWrongTypePanics(t *testing.T) {