Skip to content

Commit 27dbbc0

Browse files
committed
add refresh() method, use model_validate for external data validation
1 parent 69087ff commit 27dbbc0

2 files changed

Lines changed: 106 additions & 6 deletions

File tree

src/bubble_data_api_client/client/orm.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,15 @@ async def create(cls, **data: typing.Any) -> typing.Self:
9191
async with _get_client() as client:
9292
response = await client.create(cls._typename, aliased_data)
9393
uid = response.json()["id"]
94-
return cls(**aliased_data, **{BubbleField.ID: uid})
94+
return cls.model_validate({**aliased_data, BubbleField.ID: uid})
9595

9696
@classmethod
9797
async def get(cls, uid: str) -> typing.Self | None:
9898
"""Retrieve a single thing by its unique ID."""
9999
async with _get_client() as client:
100100
try:
101101
response = await client.retrieve(cls._typename, uid)
102-
return cls(**response.json()["response"])
102+
return cls.model_validate(response.json()["response"])
103103
except BubbleAPIError as e:
104104
if e.status_code == http.HTTPStatus.NOT_FOUND:
105105
return None
@@ -141,6 +141,26 @@ async def delete(self) -> None:
141141
async with _get_client() as client:
142142
await client.delete(self._typename, self.uid)
143143

144+
async def refresh(self) -> typing.Self:
145+
"""Fetch latest data from Bubble and update this instance in place.
146+
147+
Useful after create_or_update() to get server-computed fields like
148+
Modified Date, or fields set by Bubble workflows.
149+
150+
Returns:
151+
Self, for method chaining.
152+
153+
Raises:
154+
BubbleAPIError: If the record no longer exists (404) or other API error.
155+
"""
156+
async with _get_client() as client:
157+
response = await client.retrieve(self._typename, self.uid)
158+
cls = type(self)
159+
fresh = cls.model_validate(response.json()["response"])
160+
for field_name in cls.model_fields:
161+
setattr(self, field_name, getattr(fresh, field_name))
162+
return self
163+
144164
@classmethod
145165
async def find(
146166
cls,
@@ -178,7 +198,7 @@ async def find(
178198
exclude_remaining=exclude_remaining,
179199
additional_sort_fields=additional_sort_fields,
180200
)
181-
return [cls(**item) for item in response.json()["response"]["results"]]
201+
return [cls.model_validate(item) for item in response.json()["response"]["results"]]
182202

183203
@classmethod
184204
async def find_iter(
@@ -205,7 +225,7 @@ async def find_iter(
205225
)
206226
body = response.json()["response"]
207227
for item in body["results"]:
208-
yield cls(**item)
228+
yield cls.model_validate(item)
209229
if body["remaining"] == 0:
210230
break
211231
cursor += len(body["results"])
@@ -273,4 +293,5 @@ async def create_or_update(
273293
# construct instance from aliased data
274294
# server-side fields like Modified Date won't be populated
275295
instance_data = (aliased_create_data or {}) if result["created"] else (aliased_update_data or {})
276-
return cls(**aliased_match, **instance_data, **{BubbleField.ID: result["uids"][0]}), result["created"]
296+
model_data = {**aliased_match, **instance_data, BubbleField.ID: result["uids"][0]}
297+
return cls.model_validate(model_data), result["created"]

src/tests/unit/client/test_orm.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import json
2+
from datetime import UTC, datetime
23

34
import httpx
45
import pytest
56
import respx
67
from pydantic import Field
78

89
from bubble_data_api_client.client.orm import BubbleModel
9-
from bubble_data_api_client.exceptions import UnknownFieldError
10+
from bubble_data_api_client.exceptions import BubbleAPIError, UnknownFieldError
1011

1112

1213
def test_model_instantiation():
@@ -347,3 +348,81 @@ class User(BubbleModel, typename="user"):
347348
assert len(users) == 2
348349
assert users[0].name == "Alice"
349350
assert users[1].name == "Bob"
351+
352+
353+
@respx.mock
354+
async def test_refresh_updates_instance_in_place(configured_client: None) -> None:
355+
"""Verify refresh() fetches data and updates the instance in place."""
356+
357+
class User(BubbleModel, typename="user"):
358+
name: str
359+
email: str | None = None
360+
361+
user = User(_id="abc123", name="Old Name", email=None)
362+
363+
respx.get("https://example.com/user/abc123").mock(
364+
return_value=httpx.Response(
365+
200,
366+
json={"response": {"_id": "abc123", "name": "New Name", "email": "new@example.com"}},
367+
)
368+
)
369+
370+
result = await user.refresh()
371+
372+
# verify instance was updated in place
373+
assert user.name == "New Name"
374+
assert user.email == "new@example.com"
375+
# verify returns self for chaining
376+
assert result is user
377+
378+
379+
@respx.mock
380+
async def test_refresh_updates_server_computed_fields(configured_client: None) -> None:
381+
"""Verify refresh() populates server-computed fields like modified_date."""
382+
383+
class User(BubbleModel, typename="user"):
384+
name: str
385+
386+
user = User(_id="abc123", name="Test")
387+
assert user.modified_date is None
388+
389+
respx.get("https://example.com/user/abc123").mock(
390+
return_value=httpx.Response(
391+
200,
392+
json={
393+
"response": {
394+
"_id": "abc123",
395+
"name": "Test",
396+
"Created Date": "2024-01-15T10:30:00.000Z",
397+
"Modified Date": "2024-01-16T14:20:00.000Z",
398+
}
399+
},
400+
)
401+
)
402+
403+
await user.refresh()
404+
405+
assert user.created_date == datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC)
406+
assert user.modified_date == datetime(2024, 1, 16, 14, 20, 0, tzinfo=UTC)
407+
408+
409+
@respx.mock
410+
async def test_refresh_raises_on_not_found(configured_client: None) -> None:
411+
"""Verify refresh() raises BubbleAPIError when record no longer exists."""
412+
413+
class User(BubbleModel, typename="user"):
414+
name: str
415+
416+
user = User(_id="deleted123", name="Ghost")
417+
418+
respx.get("https://example.com/user/deleted123").mock(
419+
return_value=httpx.Response(
420+
404,
421+
json={"body": {"status": "NOT_FOUND", "message": "Thing not found"}},
422+
)
423+
)
424+
425+
with pytest.raises(BubbleAPIError) as exc_info:
426+
await user.refresh()
427+
428+
assert exc_info.value.status_code == 404

0 commit comments

Comments
 (0)