Skip to content

Commit 628da20

Browse files
committed
respect field aliases on all write operations (create, update, create_or_update)
1 parent 52db4cb commit 628da20

2 files changed

Lines changed: 151 additions & 16 deletions

File tree

src/bubble_data_api_client/client/orm.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,28 @@ def __init_subclass__(cls, *, typename: str, **kwargs: typing.Any) -> None:
2424
super().__init_subclass__(**kwargs)
2525
cls._typename = typename
2626

27+
@classmethod
28+
def _resolve_aliases(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]:
29+
"""Translate ORM field names to their aliases for API requests."""
30+
resolved: dict[str, typing.Any] = {}
31+
for field_name, value in data.items():
32+
field_info = cls.model_fields.get(field_name)
33+
if field_info is None:
34+
raise UnknownFieldError(field_name)
35+
if field_info.alias:
36+
resolved[field_info.alias] = value
37+
else:
38+
resolved[field_name] = value
39+
return resolved
40+
2741
@classmethod
2842
async def create(cls, **data: typing.Any) -> typing.Self:
43+
aliased_data = cls._resolve_aliases(data)
2944
async with _get_client() as client:
30-
response = await client.create(cls._typename, data)
45+
response = await client.create(cls._typename, aliased_data)
3146
response.raise_for_status()
3247
uid = response.json()["id"]
33-
return cls(**data, **{BubbleField.ID: uid})
48+
return cls(**aliased_data, **{BubbleField.ID: uid})
3449

3550
@classmethod
3651
async def get(cls, uid: str) -> typing.Self | None:
@@ -64,16 +79,7 @@ async def save(self) -> None:
6479
@classmethod
6580
async def update(cls, uid: str, **data: typing.Any) -> None:
6681
"""Update specific fields on a thing by its unique ID."""
67-
aliased_data: dict[str, typing.Any] = {}
68-
for field_name, value in data.items():
69-
field_info = cls.model_fields.get(field_name)
70-
if field_info is None:
71-
raise UnknownFieldError(field_name)
72-
if field_info.alias:
73-
aliased_data[field_info.alias] = value
74-
else:
75-
aliased_data[field_name] = value
76-
82+
aliased_data = cls._resolve_aliases(data)
7783
async with _get_client() as client:
7884
response = await client.update(cls._typename, uid, aliased_data)
7985
response.raise_for_status()
@@ -135,13 +141,15 @@ async def create_or_update(
135141
on_multiple: OnMultiple,
136142
) -> tuple[typing.Self, bool]:
137143
"""Create a thing if it doesn't exist, or update if it does."""
144+
aliased_match = cls._resolve_aliases(match)
145+
aliased_data = cls._resolve_aliases(data)
138146
async with _get_client() as client:
139147
result = await client.create_or_update(
140148
typename=cls._typename,
141-
match=match,
142-
data=data,
149+
match=aliased_match,
150+
data=aliased_data,
143151
on_multiple=on_multiple,
144152
)
145-
# construct instance from input data, similar to create()
153+
# construct instance from aliased data
146154
# server-side fields like Modified Date won't be populated
147-
return cls(**match, **data, **{BubbleField.ID: result["uids"][0]}), result["created"]
155+
return cls(**aliased_match, **aliased_data, **{BubbleField.ID: result["uids"][0]}), result["created"]

src/tests/unit/client/test_orm.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,130 @@ class User(BubbleBaseModel, typename="user"):
8282

8383
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
8484
await User.update(uid="abc123", nonexistent="value")
85+
86+
87+
@respx.mock
88+
async def test_create_translates_field_aliases(configured_client: None) -> None:
89+
"""Verify create() translates Python field names to Bubble aliases."""
90+
91+
class Order(BubbleBaseModel, typename="order"):
92+
company: str = Field(alias="Buying company")
93+
status: str
94+
95+
route = respx.post("https://example.com/order").mock(
96+
return_value=httpx.Response(200, json={"status": "success", "id": "new123"})
97+
)
98+
99+
order = await Order.create(company="Acme Corp", status="pending")
100+
101+
assert route.call_count == 1
102+
request_body = json.loads(route.calls[0].request.content)
103+
assert request_body == {"Buying company": "Acme Corp", "status": "pending"}
104+
assert order.company == "Acme Corp"
105+
assert order.status == "pending"
106+
assert order.uid == "new123"
107+
108+
109+
async def test_create_raises_for_unknown_field() -> None:
110+
"""Verify create() raises UnknownFieldError for fields not in the model."""
111+
112+
class User(BubbleBaseModel, typename="user"):
113+
name: str
114+
115+
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
116+
await User.create(name="test", nonexistent="value")
117+
118+
119+
@respx.mock
120+
async def test_create_or_update_translates_match_aliases(configured_client: None) -> None:
121+
"""Verify create_or_update() translates match field names to Bubble aliases."""
122+
from bubble_data_api_client.types import OnMultiple
123+
124+
class Order(BubbleBaseModel, typename="order"):
125+
external_id: str = Field(alias="External ID")
126+
company: str = Field(alias="Buying company")
127+
128+
# mock find returning no results (will create)
129+
find_route = respx.get("https://example.com/order").mock(
130+
return_value=httpx.Response(200, json={"response": {"results": [], "count": 0, "remaining": 0}})
131+
)
132+
# mock create
133+
create_route = respx.post("https://example.com/order").mock(
134+
return_value=httpx.Response(200, json={"status": "success", "id": "new123"})
135+
)
136+
137+
_order, created = await Order.create_or_update(
138+
match={"external_id": "ext-001"},
139+
data={"company": "Acme Corp"},
140+
on_multiple=OnMultiple.ERROR,
141+
)
142+
143+
assert created is True
144+
assert find_route.call_count == 1
145+
# verify find used aliased field name in constraint
146+
find_request_url = str(find_route.calls[0].request.url)
147+
assert "External%20ID" in find_request_url or "External+ID" in find_request_url
148+
149+
assert create_route.call_count == 1
150+
request_body = json.loads(create_route.calls[0].request.content)
151+
assert request_body == {"External ID": "ext-001", "Buying company": "Acme Corp"}
152+
153+
154+
@respx.mock
155+
async def test_create_or_update_translates_data_aliases(configured_client: None) -> None:
156+
"""Verify create_or_update() translates data field names to Bubble aliases."""
157+
from bubble_data_api_client.types import OnMultiple
158+
159+
class Order(BubbleBaseModel, typename="order"):
160+
external_id: str = Field(alias="External ID")
161+
company: str = Field(alias="Buying company")
162+
163+
# mock find returning one result (will update)
164+
respx.get("https://example.com/order").mock(
165+
return_value=httpx.Response(
166+
200, json={"response": {"results": [{"_id": "existing123"}], "count": 1, "remaining": 0}}
167+
)
168+
)
169+
# mock update
170+
update_route = respx.patch("https://example.com/order/existing123").mock(return_value=httpx.Response(204))
171+
172+
_order, created = await Order.create_or_update(
173+
match={"external_id": "ext-001"},
174+
data={"company": "Updated Corp"},
175+
on_multiple=OnMultiple.ERROR,
176+
)
177+
178+
assert created is False
179+
assert update_route.call_count == 1
180+
request_body = json.loads(update_route.calls[0].request.content)
181+
assert request_body == {"Buying company": "Updated Corp"}
182+
183+
184+
async def test_create_or_update_raises_for_unknown_match_field() -> None:
185+
"""Verify create_or_update() raises UnknownFieldError for unknown match fields."""
186+
from bubble_data_api_client.types import OnMultiple
187+
188+
class User(BubbleBaseModel, typename="user"):
189+
name: str
190+
191+
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
192+
await User.create_or_update(
193+
match={"nonexistent": "value"},
194+
data={"name": "test"},
195+
on_multiple=OnMultiple.ERROR,
196+
)
197+
198+
199+
async def test_create_or_update_raises_for_unknown_data_field() -> None:
200+
"""Verify create_or_update() raises UnknownFieldError for unknown data fields."""
201+
from bubble_data_api_client.types import OnMultiple
202+
203+
class User(BubbleBaseModel, typename="user"):
204+
name: str
205+
206+
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
207+
await User.create_or_update(
208+
match={"name": "test"},
209+
data={"nonexistent": "value"},
210+
on_multiple=OnMultiple.ERROR,
211+
)

0 commit comments

Comments
 (0)