Skip to content

Commit 9d43b15

Browse files
committed
add find_all() for easy retrieval of all records and find_iter() for constant memory iteration over many records
1 parent 5492895 commit 9d43b15

3 files changed

Lines changed: 241 additions & 6 deletions

File tree

README.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,18 @@ user = await User.create(name="Ada", email="ada@example.com")
2727
# retrieve
2828
user = await User.get(uid)
2929

30-
# query
30+
# query (paginated)
3131
users = await User.find(constraints=[
3232
constraint("status", ConstraintType.EQUALS, "active")
3333
])
3434

35+
# query all matching records
36+
all_users = await User.find_all()
37+
38+
# iterate with constant memory
39+
async for user in User.find_iter():
40+
process(user)
41+
3542
# update
3643
user.name = "Ada Lovelace"
3744
await user.save()
@@ -113,6 +120,7 @@ HTTP connections are pooled per event loop, avoiding reconnection overhead when
113120
- **Pydantic ORM:** define models once, get validation and autocomplete
114121
- **Connection pooling:** automatic per-event-loop client reuse
115122
- **Rich query constraints:** pythonic filtering using Bubble's constraint system
123+
- **Efficient iteration:** `find_iter()` streams records with constant memory
116124
- **Upsert with duplicate handling:** `create_or_update` with configurable strategies
117125
- **Configurable retries:** plug in your own retry policy via `tenacity`
118126
- **UID validation:** catch invalid Bubble IDs at the model level
@@ -179,14 +187,23 @@ user = await User.create(name="Ada Lovelace", email="ada@example.com")
179187
# retrieve
180188
user = await User.get("1234567890x1234567890")
181189

182-
# query with constraints
190+
# query with constraints (single page)
183191
from bubble_data_api_client import constraint, ConstraintType
184192

185193
active_users = await User.find(constraints=[
186194
constraint("status", ConstraintType.EQUALS, "active"),
187195
constraint("age", ConstraintType.GREATER_THAN, 18),
188196
])
189197

198+
# get all matching records as a list
199+
all_active = await User.find_all(constraints=[
200+
constraint("status", ConstraintType.EQUALS, "active"),
201+
])
202+
203+
# iterate through all records with constant memory
204+
async for user in User.find_iter():
205+
print(user.name)
206+
190207
# update
191208
user.name = "Ada L."
192209
await user.save()
@@ -202,13 +219,13 @@ The `create_or_update` method handles the common "upsert" pattern with configura
202219
```python
203220
from bubble_data_api_client import OnMultiple
204221

205-
# basic upsert - match by external_id, create if not found
222+
# basic upsert, matches by external_id and creates if not found
206223
user, created = await User.create_or_update(
207224
match={"external_id": "ext-123"},
208225
data={"name": "Updated Name", "email": "new@example.com"},
209226
on_multiple=OnMultiple.ERROR,
210227
)
211-
# returns (User, bool) - the model instance and whether it was created
228+
# returns (User, bool): the instance and whether it was created
212229
```
213230

214231
### Duplicate Handling Strategies
@@ -252,6 +269,32 @@ results = await User.find(constraints=constraints)
252269

253270
Available constraint types: `EQUALS`, `NOT_EQUAL`, `IS_EMPTY` (any field), `IS_NOT_EMPTY` (any field), `TEXT_CONTAINS`, `NOT_TEXT_CONTAINS`, `GREATER_THAN`, `LESS_THAN`, `IN`, `NOT_IN`, `CONTAINS`, `NOT_CONTAINS`, `EMPTY` (list fields), `NOT_EMPTY` (list fields), `GEOGRAPHIC_SEARCH`.
254271

272+
## Querying Records
273+
274+
Three methods for fetching records, depending on your needs:
275+
276+
| Method | Returns | Use case |
277+
|--------|---------|----------|
278+
| `find()` | `list` | Single page with manual pagination via `cursor`/`limit` |
279+
| `find_all()` | `list` | All matching records collected into memory |
280+
| `find_iter()` | `AsyncIterator` | All matching records with constant memory |
281+
282+
```python
283+
# find(): single page, you control pagination
284+
page1 = await User.find(limit=100)
285+
page2 = await User.find(limit=100, cursor=100)
286+
287+
# find_all(): fetches all pages, returns when complete
288+
all_users = await User.find_all(constraints=[...])
289+
print(f"Got {len(all_users)} users")
290+
291+
# find_iter(): streams records with constant memory
292+
async for user in User.find_iter(constraints=[...]):
293+
await process(user) # each record processed as it arrives
294+
```
295+
296+
Both `find_all()` and `find_iter()` handle pagination internally, fetching pages of `page_size` (default 100) until all records are retrieved.
297+
255298
## Type-Safe Bubble UIDs
256299

257300
Validate Bubble record IDs at the type level:

src/bubble_data_api_client/client/orm.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import http
22
import typing
3+
from collections.abc import AsyncIterator
34
from datetime import datetime
45

56
import httpx
67
from pydantic import BaseModel as PydanticBaseModel
78
from pydantic import Field
89

9-
from bubble_data_api_client.client.raw_client import RawClient
10+
from bubble_data_api_client.client.raw_client import AdditionalSortField, RawClient
1011
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
1112
from bubble_data_api_client.exceptions import UnknownFieldError
1213
from bubble_data_api_client.types import BubbleField, OnMultiple
@@ -125,7 +126,7 @@ async def find(
125126
sort_field: str | None = None,
126127
descending: bool | None = None,
127128
exclude_remaining: bool | None = None,
128-
additional_sort_fields: list | None = None,
129+
additional_sort_fields: list[AdditionalSortField] | None = None,
129130
) -> list[typing.Self]:
130131
async with _get_client() as client:
131132
response = await client.find(
@@ -141,6 +142,59 @@ async def find(
141142
response.raise_for_status()
142143
return [cls(**item) for item in response.json()["response"]["results"]]
143144

145+
@classmethod
146+
async def find_iter(
147+
cls,
148+
*,
149+
constraints: list[Constraint] | None = None,
150+
page_size: int = 100,
151+
sort_field: str | None = None,
152+
descending: bool | None = None,
153+
additional_sort_fields: list[AdditionalSortField] | None = None,
154+
) -> AsyncIterator[typing.Self]:
155+
"""Iterate through all matching records with constant memory usage."""
156+
cursor: int = 0
157+
async with _get_client() as client:
158+
while True:
159+
response = await client.find(
160+
cls._typename,
161+
constraints=constraints,
162+
cursor=cursor,
163+
limit=page_size,
164+
sort_field=sort_field,
165+
descending=descending,
166+
additional_sort_fields=additional_sort_fields,
167+
)
168+
response.raise_for_status()
169+
body = response.json()["response"]
170+
for item in body["results"]:
171+
yield cls(**item)
172+
if body["remaining"] == 0:
173+
break
174+
cursor += len(body["results"])
175+
176+
@classmethod
177+
async def find_all(
178+
cls,
179+
*,
180+
constraints: list[Constraint] | None = None,
181+
page_size: int = 100,
182+
sort_field: str | None = None,
183+
descending: bool | None = None,
184+
additional_sort_fields: list[AdditionalSortField] | None = None,
185+
) -> list[typing.Self]:
186+
"""Return all matching records as a list."""
187+
return [
188+
item
189+
async for item in cls.find_iter(
190+
constraints=constraints,
191+
page_size=page_size,
192+
sort_field=sort_field,
193+
descending=descending,
194+
additional_sort_fields=additional_sort_fields,
195+
)
196+
]
197+
144198
@classmethod
145199
async def count(cls, *, constraints: list[Constraint] | None = None) -> int:
146200
"""Return total count of objects matching constraints."""

src/tests/unit/client/test_orm.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,141 @@ class User(BubbleModel, typename="user"):
209209
update_data={"nonexistent": "value"},
210210
on_multiple=OnMultiple.ERROR,
211211
)
212+
213+
214+
@respx.mock
215+
async def test_find_iter_single_page(configured_client: None) -> None:
216+
"""Verify find_iter yields all items from a single page."""
217+
218+
class User(BubbleModel, typename="user"):
219+
name: str
220+
221+
respx.get("https://example.com/user").mock(
222+
return_value=httpx.Response(
223+
200,
224+
json={
225+
"response": {
226+
"results": [
227+
{"_id": "1", "name": "Alice"},
228+
{"_id": "2", "name": "Bob"},
229+
],
230+
"count": 2,
231+
"remaining": 0,
232+
}
233+
},
234+
)
235+
)
236+
237+
users = [user async for user in User.find_iter()]
238+
239+
assert len(users) == 2
240+
assert users[0].uid == "1"
241+
assert users[0].name == "Alice"
242+
assert users[1].uid == "2"
243+
assert users[1].name == "Bob"
244+
245+
246+
@respx.mock
247+
async def test_find_iter_multiple_pages(configured_client: None) -> None:
248+
"""Verify find_iter fetches all pages and yields items from each."""
249+
250+
class User(BubbleModel, typename="user"):
251+
name: str
252+
253+
route = respx.get("https://example.com/user")
254+
route.side_effect = [
255+
httpx.Response(
256+
200,
257+
json={
258+
"response": {
259+
"results": [{"_id": "1", "name": "Alice"}],
260+
"count": 1,
261+
"remaining": 2,
262+
}
263+
},
264+
),
265+
httpx.Response(
266+
200,
267+
json={
268+
"response": {
269+
"results": [{"_id": "2", "name": "Bob"}],
270+
"count": 1,
271+
"remaining": 1,
272+
}
273+
},
274+
),
275+
httpx.Response(
276+
200,
277+
json={
278+
"response": {
279+
"results": [{"_id": "3", "name": "Charlie"}],
280+
"count": 1,
281+
"remaining": 0,
282+
}
283+
},
284+
),
285+
]
286+
287+
users = [user async for user in User.find_iter(page_size=1)]
288+
289+
assert len(users) == 3
290+
assert [u.name for u in users] == ["Alice", "Bob", "Charlie"]
291+
assert route.call_count == 3
292+
293+
294+
@respx.mock
295+
async def test_find_iter_empty_results(configured_client: None) -> None:
296+
"""Verify find_iter handles empty results."""
297+
298+
class User(BubbleModel, typename="user"):
299+
name: str
300+
301+
respx.get("https://example.com/user").mock(
302+
return_value=httpx.Response(
303+
200,
304+
json={"response": {"results": [], "count": 0, "remaining": 0}},
305+
)
306+
)
307+
308+
users = [user async for user in User.find_iter()]
309+
310+
assert users == []
311+
312+
313+
@respx.mock
314+
async def test_find_all_returns_list(configured_client: None) -> None:
315+
"""Verify find_all returns all items as a list."""
316+
317+
class User(BubbleModel, typename="user"):
318+
name: str
319+
320+
route = respx.get("https://example.com/user")
321+
route.side_effect = [
322+
httpx.Response(
323+
200,
324+
json={
325+
"response": {
326+
"results": [{"_id": "1", "name": "Alice"}],
327+
"count": 1,
328+
"remaining": 1,
329+
}
330+
},
331+
),
332+
httpx.Response(
333+
200,
334+
json={
335+
"response": {
336+
"results": [{"_id": "2", "name": "Bob"}],
337+
"count": 1,
338+
"remaining": 0,
339+
}
340+
},
341+
),
342+
]
343+
344+
users = await User.find_all(page_size=1)
345+
346+
assert isinstance(users, list)
347+
assert len(users) == 2
348+
assert users[0].name == "Alice"
349+
assert users[1].name == "Bob"

0 commit comments

Comments
 (0)