Skip to content

Commit 72a2f12

Browse files
committed
serialize python types to json in orm api requests
1 parent 3887bd6 commit 72a2f12

3 files changed

Lines changed: 48 additions & 18 deletions

File tree

src/bubble_data_api_client/client/orm.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class User(BubbleModel, typename="user"):
3030
from bubble_data_api_client.client.raw_client import AdditionalSortField, RawClient
3131
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
3232
from bubble_data_api_client.exceptions import BubbleAPIError, UnknownFieldError
33-
from bubble_data_api_client.types import BubbleField, OnMultiple
33+
from bubble_data_api_client.types import BUILTIN_FIELDS, BubbleField, OnMultiple
3434

3535

3636
def _get_client() -> RawClient:
@@ -69,18 +69,17 @@ def __init_subclass__(cls, *, typename: str, **kwargs: typing.Any) -> None:
6969
cls._typename = typename
7070

7171
@classmethod
72-
def _resolve_aliases(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]:
73-
"""Translate ORM field names to their aliases for API requests."""
74-
resolved: dict[str, typing.Any] = {}
75-
for field_name, value in data.items():
76-
field_info = cls.model_fields.get(field_name)
77-
if field_info is None:
72+
def _serialize_for_api(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]:
73+
"""Serialize field data for API requests with aliasing and JSON conversion."""
74+
for field_name in data:
75+
if field_name not in cls.model_fields:
7876
raise UnknownFieldError(field_name)
79-
if field_info.alias:
80-
resolved[field_info.alias] = value
81-
else:
82-
resolved[field_name] = value
83-
return resolved
77+
partial = cls.model_construct(**data)
78+
return partial.model_dump(
79+
mode="json",
80+
include=set(data.keys()),
81+
by_alias=True,
82+
)
8483

8584
@classmethod
8685
async def create(cls, **data: typing.Any) -> typing.Self:
@@ -92,7 +91,7 @@ async def create(cls, **data: typing.Any) -> typing.Self:
9291
Returns:
9392
A new model instance with the assigned Bubble UID.
9493
"""
95-
aliased_data = cls._resolve_aliases(data)
94+
aliased_data = cls._serialize_for_api(data)
9695
async with _get_client() as client:
9796
response = await client.create(cls._typename, aliased_data)
9897
uid = response.json()["id"]
@@ -129,15 +128,16 @@ async def save(self) -> None:
129128
async with _get_client() as client:
130129
# exclude uid and server-managed fields
131130
data = self.model_dump(
132-
exclude={"uid", "created_date", "modified_date", "slug"},
131+
mode="json",
132+
exclude=BUILTIN_FIELDS,
133133
by_alias=True,
134134
)
135135
await client.update(self._typename, self.uid, data)
136136

137137
@classmethod
138138
async def update(cls, uid: str, **data: typing.Any) -> None:
139139
"""Update specific fields on a thing by its unique ID."""
140-
aliased_data = cls._resolve_aliases(data)
140+
aliased_data = cls._serialize_for_api(data)
141141
async with _get_client() as client:
142142
await client.update(cls._typename, uid, aliased_data)
143143

@@ -284,9 +284,9 @@ async def create_or_update(
284284
on_multiple: OnMultiple,
285285
) -> tuple[typing.Self, bool]:
286286
"""Create a thing if it doesn't exist, or update if it does."""
287-
aliased_match = cls._resolve_aliases(match)
288-
aliased_create_data = cls._resolve_aliases(create_data) if create_data else None
289-
aliased_update_data = cls._resolve_aliases(update_data) if update_data else None
287+
aliased_match = cls._serialize_for_api(match)
288+
aliased_create_data = cls._serialize_for_api(create_data) if create_data else None
289+
aliased_update_data = cls._serialize_for_api(update_data) if update_data else None
290290
async with _get_client() as client:
291291
result = await client.create_or_update(
292292
typename=cls._typename,

src/bubble_data_api_client/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ class BubbleField(StrEnum):
1818
SLUG = "Slug"
1919

2020

21+
class BuiltinField(StrEnum):
22+
"""Python ORM attribute names for Bubble's built-in fields."""
23+
24+
UID = "uid"
25+
CREATED_DATE = "created_date"
26+
MODIFIED_DATE = "modified_date"
27+
SLUG = "slug"
28+
29+
30+
BUILTIN_FIELDS: set[str] = set(BuiltinField)
31+
"""Python ORM attribute names for Bubble's built-in fields, as a set."""
32+
33+
2134
class OnMultiple(StrEnum):
2235
"""Strategy for handling multiple matches in create_or_update."""
2336

src/tests/unit/client/test_orm.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ class Order(BubbleModel, typename="order"):
4141
assert request_body == {"Buying company": "Acme Corp"}
4242

4343

44+
@respx.mock
45+
async def test_update_serializes_datetime(configured_client: None) -> None:
46+
"""Verify update() serializes datetime values to ISO strings."""
47+
48+
class Event(BubbleModel, typename="event"):
49+
name: str
50+
start_time: datetime
51+
52+
route = respx.patch("https://example.com/event/abc123").mock(return_value=httpx.Response(204))
53+
54+
await Event.update(uid="abc123", start_time=datetime(2026, 1, 15, 14, 30, 0))
55+
56+
assert route.call_count == 1
57+
request_body = json.loads(route.calls[0].request.content)
58+
assert request_body == {"start_time": "2026-01-15T14:30:00"}
59+
60+
4461
@respx.mock
4562
async def test_update_single_field(configured_client: None) -> None:
4663
"""Verify update() sends only the specified field."""

0 commit comments

Comments
 (0)