Skip to content

Commit d2e341a

Browse files
committed
allow different data for create vs update in create_or_update
1 parent 432c950 commit d2e341a

4 files changed

Lines changed: 146 additions & 58 deletions

File tree

src/bubble_data_api_client/client/orm.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,23 @@ async def create_or_update(
163163
cls,
164164
*,
165165
match: dict[str, typing.Any],
166-
data: dict[str, typing.Any],
166+
create_data: dict[str, typing.Any] | None = None,
167+
update_data: dict[str, typing.Any] | None = None,
167168
on_multiple: OnMultiple,
168169
) -> tuple[typing.Self, bool]:
169170
"""Create a thing if it doesn't exist, or update if it does."""
170171
aliased_match = cls._resolve_aliases(match)
171-
aliased_data = cls._resolve_aliases(data)
172+
aliased_create_data = cls._resolve_aliases(create_data) if create_data else None
173+
aliased_update_data = cls._resolve_aliases(update_data) if update_data else None
172174
async with _get_client() as client:
173175
result = await client.create_or_update(
174176
typename=cls._typename,
175177
match=aliased_match,
176-
data=aliased_data,
178+
create_data=aliased_create_data,
179+
update_data=aliased_update_data,
177180
on_multiple=on_multiple,
178181
)
179182
# construct instance from aliased data
180183
# server-side fields like Modified Date won't be populated
181-
return cls(**aliased_match, **aliased_data, **{BubbleField.ID: result["uids"][0]}), result["created"]
184+
instance_data = (aliased_create_data or {}) if result["created"] else (aliased_update_data or {})
185+
return cls(**aliased_match, **instance_data, **{BubbleField.ID: result["uids"][0]}), result["created"]

src/bubble_data_api_client/client/raw_client.py

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ async def create_or_update(
147147
typename: str,
148148
*,
149149
match: dict[str, typing.Any],
150-
data: dict[str, typing.Any],
150+
create_data: dict[str, typing.Any] | None = None,
151+
update_data: dict[str, typing.Any] | None = None,
151152
on_multiple: OnMultiple,
152153
) -> CreateOrUpdateResult:
153154
"""Create a thing if it doesn't exist, or update if it does."""
@@ -174,9 +175,9 @@ async def create_or_update(
174175
msg = "match cannot be empty"
175176
raise ValueError(msg)
176177

177-
# empty data means nothing to update/set beyond match fields
178-
if not data:
179-
msg = "data cannot be empty"
178+
# at least one of create_data or update_data must be provided
179+
if not create_data and not update_data:
180+
msg = "at least one of create_data or update_data must be provided"
180181
raise ValueError(msg)
181182

182183
# build equals constraints from match fields to find existing thing
@@ -204,17 +205,18 @@ async def create_or_update(
204205

205206
# no matches: create new thing
206207
if not results:
207-
create_data = {**match, **data}
208-
response = await self.create(typename=typename, data=create_data)
208+
merged_create_data = {**match, **(create_data or {})}
209+
response = await self.create(typename=typename, data=merged_create_data)
209210
response.raise_for_status()
210211
uid: str = response.json()["id"]
211212
return {"uids": [uid], "created": True}
212213

213-
# single match: update it
214+
# single match: update it (or skip if no update_data)
214215
if len(results) == 1:
215216
uid = results[0][BubbleField.ID]
216-
response = await self.update(typename=typename, uid=uid, data=data)
217-
response.raise_for_status()
217+
if update_data:
218+
response = await self.update(typename=typename, uid=uid, data=update_data)
219+
response.raise_for_status()
218220
return {"uids": [uid], "created": False}
219221

220222
# multiple matches: handle according to strategy
@@ -224,34 +226,36 @@ async def create_or_update(
224226

225227
case OnMultiple.UPDATE_FIRST:
226228
uid = results[0][BubbleField.ID]
227-
response = await self.update(typename=typename, uid=uid, data=data)
228-
response.raise_for_status()
229+
if update_data:
230+
response = await self.update(typename=typename, uid=uid, data=update_data)
231+
response.raise_for_status()
229232
return {"uids": [uid], "created": False}
230233

231234
case OnMultiple.UPDATE_ALL:
232235
# bubble does not support bulk PATCH, so we update concurrently
233236
uids = [result[BubbleField.ID] for result in results]
234-
results_or_errors = await asyncio.gather(
235-
*[self.update(typename=typename, uid=uid, data=data) for uid in uids],
236-
return_exceptions=True,
237-
)
238-
239-
# check for failures, letting all operations complete before raising
240-
succeeded: list[str] = []
241-
failed: list[tuple[str, BaseException]] = []
242-
for uid, item in zip(uids, results_or_errors, strict=True):
243-
if isinstance(item, BaseException):
244-
failed.append((uid, item))
245-
else:
246-
item.raise_for_status()
247-
succeeded.append(uid)
248-
249-
if failed:
250-
raise PartialFailureError(
251-
operation="update",
252-
succeeded=succeeded,
253-
failed=failed,
237+
if update_data:
238+
results_or_errors = await asyncio.gather(
239+
*[self.update(typename=typename, uid=uid, data=update_data) for uid in uids],
240+
return_exceptions=True,
254241
)
242+
243+
# check for failures, letting all operations complete before raising
244+
succeeded: list[str] = []
245+
failed: list[tuple[str, BaseException]] = []
246+
for uid, item in zip(uids, results_or_errors, strict=True):
247+
if isinstance(item, BaseException):
248+
failed.append((uid, item))
249+
else:
250+
item.raise_for_status()
251+
succeeded.append(uid)
252+
253+
if failed:
254+
raise PartialFailureError(
255+
operation="update",
256+
succeeded=succeeded,
257+
failed=failed,
258+
)
255259
return {"uids": uids, "created": False}
256260

257261
case (
@@ -265,8 +269,9 @@ async def create_or_update(
265269
delete_uids = [r[BubbleField.ID] for r in results[1:]]
266270

267271
# update first so data is preserved even if deletes fail
268-
response = await self.update(typename=typename, uid=keep_uid, data=data)
269-
response.raise_for_status()
272+
if update_data:
273+
response = await self.update(typename=typename, uid=keep_uid, data=update_data)
274+
response.raise_for_status()
270275

271276
# delete duplicates concurrently, letting all complete before checking errors
272277
delete_results = await asyncio.gather(

src/tests/unit/client/test_create_or_update.py

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def test_create_or_update_creates_when_no_match(configured_client: None) -
4545
result = await client.create_or_update(
4646
typename="customer",
4747
match={"external_id": "abc"},
48-
data={"name": "John"},
48+
create_data={"name": "John"},
4949
on_multiple=OnMultiple.ERROR,
5050
)
5151

@@ -55,6 +55,86 @@ async def test_create_or_update_creates_when_no_match(configured_client: None) -
5555
assert create_route.call_count == 1
5656

5757

58+
@respx.mock
59+
async def test_create_or_update_with_both_create_and_update_data_creates(configured_client: None) -> None:
60+
"""Test that create_data is used when creating, not update_data."""
61+
import json
62+
63+
respx.get("https://test.example.com/customer").mock(
64+
return_value=httpx.Response(200, json={"response": {"results": [], "count": 0, "remaining": 0}})
65+
)
66+
create_route = respx.post("https://test.example.com/customer").mock(
67+
return_value=httpx.Response(200, json={"status": "success", "id": "new123"})
68+
)
69+
70+
async with RawClient() as client:
71+
result = await client.create_or_update(
72+
typename="customer",
73+
match={"external_id": "abc"},
74+
create_data={"status": "new", "created_by": "system"},
75+
update_data={"status": "active", "last_seen": "2024-01-01"},
76+
on_multiple=OnMultiple.ERROR,
77+
)
78+
79+
assert result["created"] is True
80+
assert result["uids"] == ["new123"]
81+
# verify create was called with match + create_data (not update_data)
82+
request_body = json.loads(create_route.calls[0].request.content)
83+
assert request_body == {"external_id": "abc", "status": "new", "created_by": "system"}
84+
85+
86+
@respx.mock
87+
async def test_create_or_update_with_both_create_and_update_data_updates(configured_client: None) -> None:
88+
"""Test that update_data is used when updating, not create_data."""
89+
import json
90+
91+
respx.get("https://test.example.com/customer").mock(
92+
return_value=httpx.Response(
93+
200, json={"response": {"results": [{"_id": "existing123"}], "count": 1, "remaining": 0}}
94+
)
95+
)
96+
update_route = respx.patch("https://test.example.com/customer/existing123").mock(return_value=httpx.Response(204))
97+
98+
async with RawClient() as client:
99+
result = await client.create_or_update(
100+
typename="customer",
101+
match={"external_id": "abc"},
102+
create_data={"status": "new", "created_by": "system"},
103+
update_data={"status": "active", "last_seen": "2024-01-01"},
104+
on_multiple=OnMultiple.ERROR,
105+
)
106+
107+
assert result["created"] is False
108+
assert result["uids"] == ["existing123"]
109+
# verify update was called with update_data (not create_data)
110+
request_body = json.loads(update_route.calls[0].request.content)
111+
assert request_body == {"status": "active", "last_seen": "2024-01-01"}
112+
113+
114+
@respx.mock
115+
async def test_create_or_update_with_only_create_data_skips_update(configured_client: None) -> None:
116+
"""Test that when only create_data is provided, updates are skipped."""
117+
find_route = respx.get("https://test.example.com/customer").mock(
118+
return_value=httpx.Response(
119+
200, json={"response": {"results": [{"_id": "existing123"}], "count": 1, "remaining": 0}}
120+
)
121+
)
122+
# no update route - should not be called
123+
124+
async with RawClient() as client:
125+
result = await client.create_or_update(
126+
typename="customer",
127+
match={"external_id": "abc"},
128+
create_data={"status": "new"},
129+
on_multiple=OnMultiple.ERROR,
130+
)
131+
132+
assert result["created"] is False
133+
assert result["uids"] == ["existing123"]
134+
assert find_route.call_count == 1
135+
# no PATCH call should have been made
136+
137+
58138
@respx.mock
59139
async def test_create_or_update_updates_when_single_match(configured_client: None) -> None:
60140
"""Test that create_or_update updates when exactly one match is found."""
@@ -71,7 +151,7 @@ async def test_create_or_update_updates_when_single_match(configured_client: Non
71151
result = await client.create_or_update(
72152
typename="customer",
73153
match={"external_id": "abc"},
74-
data={"name": "John"},
154+
update_data={"name": "John"},
75155
on_multiple=OnMultiple.ERROR,
76156
)
77157

@@ -103,7 +183,7 @@ async def test_create_or_update_error_on_multiple_matches(configured_client: Non
103183
await client.create_or_update(
104184
typename="customer",
105185
match={"external_id": "abc"},
106-
data={"name": "John"},
186+
update_data={"name": "John"},
107187
on_multiple=OnMultiple.ERROR,
108188
)
109189

@@ -132,7 +212,7 @@ async def test_create_or_update_update_first(configured_client: None) -> None:
132212
result = await client.create_or_update(
133213
typename="customer",
134214
match={"external_id": "abc"},
135-
data={"name": "John"},
215+
update_data={"name": "John"},
136216
on_multiple=OnMultiple.UPDATE_FIRST,
137217
)
138218

@@ -164,7 +244,7 @@ async def test_create_or_update_update_all(configured_client: None) -> None:
164244
result = await client.create_or_update(
165245
typename="customer",
166246
match={"external_id": "abc"},
167-
data={"name": "John"},
247+
update_data={"name": "John"},
168248
on_multiple=OnMultiple.UPDATE_ALL,
169249
)
170250

@@ -199,7 +279,7 @@ async def test_create_or_update_dedupe_oldest_created(configured_client: None) -
199279
result = await client.create_or_update(
200280
typename="customer",
201281
match={"external_id": "abc"},
202-
data={"name": "John"},
282+
update_data={"name": "John"},
203283
on_multiple=OnMultiple.DEDUPE_OLDEST_CREATED,
204284
)
205285

@@ -234,7 +314,7 @@ async def test_create_or_update_dedupe_newest_created(configured_client: None) -
234314
result = await client.create_or_update(
235315
typename="customer",
236316
match={"external_id": "abc"},
237-
data={"name": "John"},
317+
update_data={"name": "John"},
238318
on_multiple=OnMultiple.DEDUPE_NEWEST_CREATED,
239319
)
240320

@@ -269,7 +349,7 @@ async def test_create_or_update_dedupe_oldest_modified(configured_client: None)
269349
result = await client.create_or_update(
270350
typename="customer",
271351
match={"external_id": "abc"},
272-
data={"name": "John"},
352+
update_data={"name": "John"},
273353
on_multiple=OnMultiple.DEDUPE_OLDEST_MODIFIED,
274354
)
275355

@@ -304,7 +384,7 @@ async def test_create_or_update_dedupe_newest_modified(configured_client: None)
304384
result = await client.create_or_update(
305385
typename="customer",
306386
match={"external_id": "abc"},
307-
data={"name": "John"},
387+
update_data={"name": "John"},
308388
on_multiple=OnMultiple.DEDUPE_NEWEST_MODIFIED,
309389
)
310390

@@ -322,7 +402,7 @@ async def test_create_or_update_invalid_on_multiple(configured_client: None) ->
322402
await client.create_or_update(
323403
typename="customer",
324404
match={"external_id": "abc"},
325-
data={"name": "John"},
405+
update_data={"name": "John"},
326406
on_multiple="invalid", # type: ignore[arg-type]
327407
)
328408

@@ -334,19 +414,18 @@ async def test_create_or_update_empty_match_raises(configured_client: None) -> N
334414
await client.create_or_update(
335415
typename="customer",
336416
match={},
337-
data={"name": "John"},
417+
update_data={"name": "John"},
338418
on_multiple=OnMultiple.ERROR,
339419
)
340420

341421

342-
async def test_create_or_update_empty_data_raises(configured_client: None) -> None:
343-
"""Test that empty data dict raises ValueError."""
422+
async def test_create_or_update_no_data_raises(configured_client: None) -> None:
423+
"""Test that no data provided raises ValueError."""
344424
async with RawClient() as client:
345-
with pytest.raises(ValueError, match="data cannot be empty"):
425+
with pytest.raises(ValueError, match="at least one of create_data or update_data must be provided"):
346426
await client.create_or_update(
347427
typename="customer",
348428
match={"external_id": "abc"},
349-
data={},
350429
on_multiple=OnMultiple.ERROR,
351430
)
352431

@@ -378,7 +457,7 @@ async def test_create_or_update_update_all_partial_failure(configured_client: No
378457
await client.create_or_update(
379458
typename="customer",
380459
match={"external_id": "abc"},
381-
data={"name": "John"},
460+
update_data={"name": "John"},
382461
on_multiple=OnMultiple.UPDATE_ALL,
383462
)
384463

@@ -417,7 +496,7 @@ async def test_create_or_update_dedupe_partial_delete_failure(configured_client:
417496
await client.create_or_update(
418497
typename="customer",
419498
match={"external_id": "abc"},
420-
data={"name": "John"},
499+
update_data={"name": "John"},
421500
on_multiple=OnMultiple.DEDUPE_OLDEST_CREATED,
422501
)
423502

src/tests/unit/client/test_orm.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ class Order(BubbleModel, typename="order"):
136136

137137
_order, created = await Order.create_or_update(
138138
match={"external_id": "ext-001"},
139-
data={"company": "Acme Corp"},
139+
create_data={"company": "Acme Corp"},
140140
on_multiple=OnMultiple.ERROR,
141141
)
142142

@@ -171,7 +171,7 @@ class Order(BubbleModel, typename="order"):
171171

172172
_order, created = await Order.create_or_update(
173173
match={"external_id": "ext-001"},
174-
data={"company": "Updated Corp"},
174+
update_data={"company": "Updated Corp"},
175175
on_multiple=OnMultiple.ERROR,
176176
)
177177

@@ -191,7 +191,7 @@ class User(BubbleModel, typename="user"):
191191
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
192192
await User.create_or_update(
193193
match={"nonexistent": "value"},
194-
data={"name": "test"},
194+
update_data={"name": "test"},
195195
on_multiple=OnMultiple.ERROR,
196196
)
197197

@@ -206,6 +206,6 @@ class User(BubbleModel, typename="user"):
206206
with pytest.raises(UnknownFieldError, match="unknown field: nonexistent"):
207207
await User.create_or_update(
208208
match={"name": "test"},
209-
data={"nonexistent": "value"},
209+
update_data={"nonexistent": "value"},
210210
on_multiple=OnMultiple.ERROR,
211211
)

0 commit comments

Comments
 (0)