Skip to content

Commit 6f18541

Browse files
committed
add find_page for paginated queries with envelope metadata
1 parent 725883a commit 6f18541

6 files changed

Lines changed: 562 additions & 6 deletions

File tree

src/bubble_data_api_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
OnMultiple,
2929
OptionalBubbleUID,
3030
OptionalBubbleUIDs,
31+
PageResult,
3132
)
3233
from bubble_data_api_client.validation import filter_bubble_uids, is_bubble_uid
3334

@@ -56,6 +57,7 @@
5657
"OnMultiple",
5758
"OptionalBubbleUID",
5859
"OptionalBubbleUIDs",
60+
"PageResult",
5961
# validation
6062
"filter_bubble_uids",
6163
"is_bubble_uid",

src/bubble_data_api_client/client/orm.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ class User(BubbleModel, typename="user"):
2929
from pydantic import BaseModel as PydanticBaseModel
3030
from pydantic import Field
3131

32-
from bubble_data_api_client.client.raw_client import AdditionalSortField, RawClient
32+
from bubble_data_api_client.client.raw_client import (
33+
_DEFAULT_PAGE_SIZE,
34+
AdditionalSortField,
35+
RawClient,
36+
)
3337
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
3438
from bubble_data_api_client.exceptions import BubbleAPIError, UnknownFieldError
35-
from bubble_data_api_client.types import BUILTIN_FIELDS, BubbleField, OnMultiple
36-
37-
# default page size for paginated requests.
38-
_DEFAULT_PAGE_SIZE: int = 100
39+
from bubble_data_api_client.types import BUILTIN_FIELDS, BubbleField, OnMultiple, PageResult
3940

4041
# max UIDs per "in" constraint batch, matching the API's max page size.
4142
_MAX_IN_CONSTRAINT_SIZE: int = 100
@@ -219,6 +220,55 @@ async def find(
219220
)
220221
return [cls.model_validate(item) for item in response.json()["response"]["results"]]
221222

223+
@classmethod
224+
async def find_page(
225+
cls,
226+
*,
227+
constraints: list[Constraint] | None = None,
228+
cursor: int = 0,
229+
limit: int = _DEFAULT_PAGE_SIZE,
230+
sort_field: str | None = None,
231+
descending: bool | None = None,
232+
additional_sort_fields: list[AdditionalSortField] | None = None,
233+
) -> PageResult[typing.Self]:
234+
"""Return one page of matching records with envelope metadata.
235+
236+
Unlike find(), this preserves Bubble's response envelope so callers
237+
can display total counts and drive pagination UIs without issuing a
238+
separate count() call.
239+
240+
See RawClient.find_page for important caveats about Bubble's
241+
pagination limits: the 100-item silent cap on limit, the ~50,000
242+
cursor cap on shared infrastructure, and the recommended keyset
243+
pagination workaround for collections larger than the cursor cap.
244+
245+
Args:
246+
constraints: Filter conditions (use constraint() helper to build).
247+
cursor: Pagination offset (0-indexed).
248+
limit: Maximum results to return on this page.
249+
sort_field: Field name to sort by.
250+
descending: Sort in descending order if True.
251+
additional_sort_fields: Secondary sort fields after the primary.
252+
253+
Returns:
254+
PageResult with typed model instances plus envelope metadata.
255+
"""
256+
async with _get_client() as client:
257+
page = await client.find_page(
258+
cls._typename,
259+
constraints=constraints,
260+
cursor=cursor,
261+
limit=limit,
262+
sort_field=sort_field,
263+
descending=descending,
264+
additional_sort_fields=additional_sort_fields,
265+
)
266+
return PageResult(
267+
items=[cls.model_validate(item) for item in page.items],
268+
cursor=page.cursor,
269+
remaining=page.remaining,
270+
)
271+
222272
@classmethod
223273
async def find_iter(
224274
cls,
@@ -229,7 +279,15 @@ async def find_iter(
229279
descending: bool | None = None,
230280
additional_sort_fields: list[AdditionalSortField] | None = None,
231281
) -> AsyncIterator[typing.Self]:
232-
"""Iterate through all matching records with constant memory usage."""
282+
"""Iterate through all matching records with constant memory usage.
283+
284+
Offset pagination is capped at ~50,000 on Bubble's shared
285+
infrastructure. For collections larger than that cap, prefer
286+
keyset pagination (a Created Date constraint) instead of this
287+
method, which will stop short at the cap. The empty-page guard
288+
below prevents an infinite loop when the cursor reaches the cap
289+
(Bubble continues to report a non-zero remaining past the cap).
290+
"""
233291
cursor: int = 0
234292
async with _get_client() as client:
235293
while True:
@@ -245,6 +303,11 @@ async def find_iter(
245303
body = response.json()["response"]
246304
for item in body["results"]:
247305
yield cls.model_validate(item)
306+
# stop on empty page first: past the cursor cap Bubble can
307+
# return zero results while still reporting remaining > 0,
308+
# which would otherwise cause an infinite loop.
309+
if not body["results"]:
310+
break
248311
if body["remaining"] == 0:
249312
break
250313
cursor += len(body["results"])

src/bubble_data_api_client/client/raw_client.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@
3838
BulkCreateItemResult,
3939
CreateOrUpdateResult,
4040
OnMultiple,
41+
PageResult,
4142
)
4243

44+
# Bubble Data API returns 100 items per page by default and silently caps any
45+
# requested limit at 100 (no HTTP error, no warning). Verified empirically up
46+
# to limit=2,147,483,647 (int32 max).
47+
# https://manual.bubble.io/help-guides/integrations/api/the-bubble-api/the-data-api/data-api-requests
48+
_DEFAULT_PAGE_SIZE: int = 100
49+
4350

4451
# https://manual.bubble.io/core-resources/api/the-bubble-api/the-data-api/data-api-requests#sorting
4552
# in addition to 'sort_field' and 'descending', it is possible to have
@@ -182,6 +189,73 @@ async def find(
182189

183190
return await self._transport.get(f"/{typename}", params=params)
184191

192+
async def find_page(
193+
self,
194+
typename: str,
195+
*,
196+
constraints: list[Constraint] | None = None,
197+
cursor: int = 0,
198+
limit: int = _DEFAULT_PAGE_SIZE,
199+
sort_field: str | None = None,
200+
descending: bool | None = None,
201+
additional_sort_fields: list[AdditionalSortField] | None = None,
202+
) -> PageResult[dict[str, typing.Any]]:
203+
"""Return one page of results with envelope metadata.
204+
205+
Unlike find(), this parses Bubble's response envelope so callers can
206+
display total counts and drive pagination UIs without issuing a separate
207+
count() call. The returned PageResult exposes cursor, remaining, and a
208+
computed total derived from len(items) (which is robust to the edge
209+
case where Bubble returns count=-1 for limit=-1).
210+
211+
Bubble Data API pagination limits to be aware of:
212+
213+
- limit is silently capped at 100 items. Requesting more returns
214+
exactly 100 with HTTP 200; PageResult does not echo the
215+
requested limit, so there is no way to accidentally advance a
216+
cursor by a capped value. Use len(page.items) to advance.
217+
- cursor + page size is capped at roughly 50,000 on shared
218+
infrastructure (higher on Enterprise plans). Beyond this offset,
219+
Bubble returns zero results but continues to report a non-zero
220+
remaining, making PageResult.total under-report past the cap.
221+
- For collections larger than the cursor cap, do not use offset
222+
pagination. Use keyset pagination: sort by a monotonic field (e.g.
223+
Created Date) with a greater-than constraint on the last-seen
224+
value, resetting cursor to 0 on each request.
225+
226+
Args:
227+
typename: The Bubble data type to query.
228+
constraints: Filter conditions (use constraint() helper to build).
229+
cursor: Pagination offset (0-indexed).
230+
limit: Maximum results to return on this page.
231+
sort_field: Field name to sort by.
232+
descending: Sort in descending order if True.
233+
additional_sort_fields: Secondary sort fields after the primary.
234+
235+
Returns:
236+
PageResult with items as raw dicts plus envelope metadata.
237+
"""
238+
response = await self.find(
239+
typename,
240+
constraints=constraints,
241+
cursor=cursor,
242+
limit=limit,
243+
sort_field=sort_field,
244+
descending=descending,
245+
additional_sort_fields=additional_sort_fields,
246+
)
247+
body = response.json()["response"]
248+
# Prefer the server-reported cursor over the requested value so
249+
# PageResult is a pure reflection of server state. Bubble echoes
250+
# cursor on every observed response; if it ever normalizes the
251+
# value (e.g. a negative cursor to 0), this surfaces the difference
252+
# immediately rather than silently returning wrong data.
253+
return PageResult(
254+
items=body["results"],
255+
cursor=body["cursor"],
256+
remaining=body["remaining"],
257+
)
258+
185259
async def count(
186260
self,
187261
typename: str,

src/bubble_data_api_client/types.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Bubble platform types for use with Pydantic models."""
22

3+
from dataclasses import dataclass
34
from enum import StrEnum
45
from typing import Annotated, Any, Literal, TypedDict
56

@@ -62,6 +63,45 @@ class BulkCreateItemResult(TypedDict):
6263
message: str | None
6364

6465

66+
@dataclass(frozen=True, slots=True)
67+
class PageResult[T]:
68+
"""One page of results from a Bubble find query, with envelope metadata.
69+
70+
To advance pagination, use cursor + len(items), not any notion of page
71+
size. Bubble silently caps requested limits at 100, so a caller who
72+
requested more than 100 items must not assume they got what they asked
73+
for; len(items) is always the correct advancement step.
74+
75+
Attributes:
76+
items: Results for this page, in Bubble's returned order.
77+
cursor: The cursor (offset) that produced this page, as reported
78+
by Bubble's response envelope.
79+
remaining: Number of matching records after this page.
80+
"""
81+
82+
items: list[T]
83+
cursor: int
84+
remaining: int
85+
86+
@property
87+
def total(self) -> int:
88+
"""Return total number of records matching the query.
89+
90+
Computed as cursor + len(items) + remaining. This under-reports
91+
past Bubble's ~50,000 cursor cap on shared infrastructure, where
92+
Bubble returns an empty page with a non-zero remaining value. For
93+
collections larger than the cap, use keyset pagination (sort by a
94+
monotonic field with a greater-than constraint) rather than
95+
offset pagination.
96+
"""
97+
return self.cursor + len(self.items) + self.remaining
98+
99+
@property
100+
def has_more(self) -> bool:
101+
"""Return True if there are more pages after this one."""
102+
return self.remaining > 0
103+
104+
65105
def _validate_bubble_uid(value: str) -> str:
66106
"""Validate that a string is a valid Bubble UID."""
67107
if not is_bubble_uid(value):

0 commit comments

Comments
 (0)