Coverage for src/lexigram/graphql/pagination/helpers.py: 14%
91 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Utility routines for GraphQL pagination.
3This module hosts the cursor encoding/decoding logic and pagination helper
4functions. Previously these were in the package root alongside the data
5classes; they have now been moved here to keep ``__init__`` lean.
6"""
8from __future__ import annotations
10import base64
11import binascii
12from typing import TYPE_CHECKING, TypeVar
14from lexigram.graphql.pagination.types import (
15 CursorConnection,
16 CursorPaginationInput,
17 Edge,
18 OffsetPaginationInput,
19 PageInfo,
20 PaginationResult,
21)
23if TYPE_CHECKING:
24 from collections.abc import Sequence
26# Cursor helpers -------------------------------------------------------------
28T = TypeVar("T")
31def encode_cursor(offset: int) -> str:
32 """Encode an offset as a base64 cursor.
34 Args:
35 offset: The offset to encode.
37 Returns:
38 Base64-encoded cursor string.
39 """
40 cursor = f"cursor:{offset}"
41 return base64.b64encode(cursor.encode()).decode()
44def decode_cursor(cursor: str) -> int:
45 """Decode a base64 cursor to an offset.
47 Args:
48 cursor: The cursor string to decode.
50 Returns:
51 The decoded offset.
53 Raises:
54 ValueError: If the cursor is invalid.
55 """
56 try:
57 decoded = base64.b64decode(cursor.encode()).decode()
58 if decoded.startswith("cursor:"):
59 return int(decoded[7:])
60 except (ValueError, binascii.Error):
61 pass
62 raise ValueError(f"Invalid cursor: {cursor}")
65def encode_cursor_from_id(item_id: str) -> str:
66 """Encode an item ID as an opaque cursor.
68 Args:
69 item_id: The item ID to encode.
71 Returns:
72 Base64-encoded cursor string.
73 """
74 cursor = f"id:{item_id}"
75 return base64.b64encode(cursor.encode()).decode()
78def decode_cursor_to_id(cursor: str) -> str:
79 """Decode a cursor to an item ID.
81 Args:
82 cursor: The cursor string to decode.
84 Returns:
85 The decoded item ID.
87 Raises:
88 ValueError: If the cursor is invalid.
89 """
90 try:
91 decoded = base64.b64decode(cursor.encode()).decode()
92 if decoded.startswith("id:"):
93 return decoded[3:]
94 except (ValueError, binascii.Error):
95 pass
96 raise ValueError(f"Invalid cursor: {cursor}")
99# Pagination implementations ------------------------------------------------
102async def paginate_connection(
103 items: Sequence[T],
104 pagination: CursorPaginationInput | dict,
105 total_count: int | None = None,
106) -> CursorConnection[T]:
107 """Create a Relay Connection from a list of items.
109 Args:
110 items: Sequence of items to paginate.
111 pagination: Cursor pagination input.
112 total_count: Total count of items (if known).
114 Returns:
115 A Connection with edges and page info.
116 """
117 if isinstance(pagination, dict):
118 pagination = CursorPaginationInput(**pagination)
120 pagination.validate()
122 # Calculate offsets
123 start_offset = 0
124 end_offset = len(items)
126 if pagination.after:
127 try:
128 start_offset = decode_cursor(pagination.after) + 1
129 except ValueError:
130 start_offset = 0
132 if pagination.before:
133 try:
134 end_offset = decode_cursor(pagination.before)
135 except ValueError:
136 end_offset = len(items)
138 # Apply first/last limits
139 if pagination.first is not None:
140 end_offset = min(start_offset + pagination.first, end_offset)
141 elif pagination.last is not None:
142 start_offset = max(end_offset - pagination.last, start_offset)
144 # Get the slice
145 page_items = items[start_offset:end_offset]
147 # Calculate total if not provided
148 if total_count is None:
149 total_count = len(items)
151 # Create edges
152 edges = []
153 for i, item in enumerate(page_items):
154 offset = start_offset + i
155 edge = Edge(
156 node=item,
157 cursor=encode_cursor(offset),
158 )
159 edges.append(edge)
161 # Calculate has_next/has_previous
162 has_next = end_offset < len(items)
163 has_previous = start_offset > 0
165 page_info = PageInfo(
166 has_next_page=has_next,
167 has_previous_page=has_previous,
168 start_cursor=edges[0].cursor if edges else None,
169 end_cursor=edges[-1].cursor if edges else None,
170 )
172 return CursorConnection(
173 edges=edges,
174 page_info=page_info,
175 )
178async def paginate_offset(
179 items: Sequence[T],
180 pagination: OffsetPaginationInput | dict,
181 total_count: int | None = None,
182) -> PaginationResult[T]:
183 """Paginate using offset-based pagination.
185 Args:
186 items: Sequence of items to paginate.
187 pagination: Offset pagination input.
188 total_count: Total count of items (if known).
190 Returns:
191 PaginationResult with items and metadata.
192 """
193 if isinstance(pagination, dict):
194 pagination = OffsetPaginationInput(**pagination)
196 pagination.validate()
198 # Calculate total if not provided
199 if total_count is None:
200 total_count = len(items)
202 # Apply offset and limit
203 offset = pagination.offset
204 limit = min(pagination.offset + pagination.limit, total_count)
206 page_items = items[offset:limit]
208 # Calculate has_next/has_previous
209 has_next = limit < total_count
210 has_previous = offset > 0
212 return PaginationResult(
213 items=list(page_items),
214 total_count=total_count,
215 has_next=has_next,
216 has_previous=has_previous,
217 )
220def calculate_offset(
221 first: int | None,
222 after: str | None,
223 last: int | None,
224 before: str | None,
225 default_first: int = 10,
226) -> tuple[int, int]:
227 """Calculate offset and limit from cursor pagination inputs.
229 Args:
230 first: Number of items forward.
231 after: Cursor to start after.
232 last: Number of items backward.
233 before: Cursor to start before.
234 default_first: Default number of items if not specified.
236 Returns:
237 Tuple of (offset, limit).
238 """
239 offset = 0
240 limit = default_first
242 # Determine direction
243 if after:
244 try:
245 offset = decode_cursor(after) + 1
246 except ValueError:
247 offset = 0
249 if before:
250 try:
251 offset = decode_cursor(before) - default_first
252 offset = max(offset, 0)
253 except ValueError:
254 pass
256 if first is not None:
257 limit = first
258 elif last is not None:
259 limit = last
261 return offset, limit