hybrid pagination

This commit is contained in:
Mazyar
2026-05-17 17:21:36 +03:30
parent 42d0a47226
commit 6dac5b482a
39 changed files with 696 additions and 386 deletions
+22 -6
View File
@@ -37,6 +37,7 @@ type PaginatedResult[T any] struct {
TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter
NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page
HasMore bool `json:"hasMore"` // Whether there are more documents
PageOffset int `json:"pageOffset"` // Zero-based offset of the first item in this page
}
// Repository defines the interface for MongoDB operations
@@ -144,6 +145,16 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
return nil, fmt.Errorf("failed to clone filter for count: %w", err)
}
filtersHash := HashFilter(filter)
pageOffset := pagination.Skip
if pagination.Cursor != "" {
pageCursor, err := decodePageCursor(pagination.Cursor)
if err != nil {
return nil, err
}
pageOffset = pageCursor.PageOffset
}
// Build find options
findOptions := options.Find().
SetSort(sort).
@@ -155,7 +166,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
// Handle cursor-based pagination
if pagination.Cursor != "" {
cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder)
cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder, filtersHash)
if err != nil {
return nil, err
}
@@ -209,12 +220,13 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
// Generate next cursor
var nextCursor string
if hasMore && len(items) > 0 {
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder)
nextPageOffset := pageOffset + pagination.Limit
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder, filtersHash, nextPageOffset)
if err != nil {
r.logger.Error("Failed to generate next cursor", map[string]interface{}{
"error": err.Error(),
})
// Don't fail the entire query, just leave cursor empty
return nil, fmt.Errorf("failed to generate pagination cursor: %w", err)
}
}
@@ -223,6 +235,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
TotalCount: totalCount,
NextCursor: nextCursor,
HasMore: hasMore,
PageOffset: pageOffset,
}, nil
}
@@ -454,7 +467,7 @@ func (r *repository[T]) validatePagination(pagination Pagination) error {
}
// buildCursorFilter builds a filter for cursor-based pagination.
func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int) (bson.M, error) {
func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int, filtersHash string) (bson.M, error) {
pageCursor, err := decodePageCursor(cursor)
if err != nil {
return nil, err
@@ -462,11 +475,14 @@ func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder in
if !pageCursor.matches(sortField, sortOrder) {
return nil, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor)
}
if pageCursor.FiltersHash != filtersHash {
return nil, fmt.Errorf("%w: cursor does not match current filters", ErrInvalidCursor)
}
return pageCursor.mongoFilter()
}
// generateCursor generates an opaque cursor from the last document in a page.
func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) (string, error) {
func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int, filtersHash string, pageOffset int) (string, error) {
docBytes, err := bson.Marshal(doc)
if err != nil {
return "", fmt.Errorf("failed to marshal document: %w", err)
@@ -487,7 +503,7 @@ func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) (
return "", err
}
return encodePageCursor(sortField, sortOrder, sortValue, idHex)
return encodePageCursor(sortField, sortOrder, sortValue, idHex, filtersHash, pageOffset)
}
// getModelID extracts the ID from a model