From 683b0ddbf4f920c7fb970ccee7c93554b358d092 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 17:14:05 -0700 Subject: [PATCH] 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 --- README.md | 8 ++++++ cursor.go | 68 +++++++++++++++++++++++++++++-------------- doc.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ edge.go | 5 ++-- list.go | 6 ++-- list_test.go | 5 ++-- query.go | 29 ++----------------- query_count_test.go | 9 +++--- 8 files changed, 141 insertions(+), 59 deletions(-) create mode 100644 doc.go 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) {