Skip to content

Commit 4b1dbdf

Browse files
committed
add modified date support to dedupe strategies for finer control over which duplicate to keep
1 parent b14c524 commit 4b1dbdf

3 files changed

Lines changed: 97 additions & 15 deletions

File tree

src/bubble_data_api_client/client/raw_client.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,10 @@ async def create_or_update(
161161
# - ERROR: raise MultipleMatchesError
162162
# - UPDATE_FIRST: update the first match (arbitrary order)
163163
# - UPDATE_ALL: update all matches (N API calls, no bulk update in Bubble)
164-
# - DEDUPE_OLDEST: delete all but oldest, update oldest (N API calls)
165-
# - DEDUPE_NEWEST: delete all but newest, update newest (N API calls)
164+
# - DEDUPE_OLDEST_CREATED: delete all but oldest by created date (N API calls)
165+
# - DEDUPE_NEWEST_CREATED: delete all but newest by created date (N API calls)
166+
# - DEDUPE_OLDEST_MODIFIED: delete all but oldest by modified date (N API calls)
167+
# - DEDUPE_NEWEST_MODIFIED: delete all but newest by modified date (N API calls)
166168

167169
if on_multiple not in OnMultiple:
168170
raise InvalidOnMultipleError(on_multiple)
@@ -182,12 +184,15 @@ async def create_or_update(
182184
constraint(key=key, constraint_type=ConstraintType.EQUALS, value=value) for key, value in match.items()
183185
]
184186

185-
# for dedupe strategies, sort by created date to determine oldest/newest
187+
# for dedupe strategies, sort by date to determine oldest/newest
186188
sort_field: str | None = None
187189
descending: bool | None = None
188-
if on_multiple in (OnMultiple.DEDUPE_OLDEST, OnMultiple.DEDUPE_NEWEST):
190+
if on_multiple in (OnMultiple.DEDUPE_OLDEST_CREATED, OnMultiple.DEDUPE_NEWEST_CREATED):
189191
sort_field = BubbleField.CREATED_DATE
190-
descending = on_multiple == OnMultiple.DEDUPE_NEWEST
192+
descending = on_multiple == OnMultiple.DEDUPE_NEWEST_CREATED
193+
elif on_multiple in (OnMultiple.DEDUPE_OLDEST_MODIFIED, OnMultiple.DEDUPE_NEWEST_MODIFIED):
194+
sort_field = BubbleField.MODIFIED_DATE
195+
descending = on_multiple == OnMultiple.DEDUPE_NEWEST_MODIFIED
191196

192197
response = await self.find(
193198
typename=typename,
@@ -249,7 +254,12 @@ async def create_or_update(
249254
)
250255
return {"uids": uids, "created": False}
251256

252-
case OnMultiple.DEDUPE_OLDEST | OnMultiple.DEDUPE_NEWEST:
257+
case (
258+
OnMultiple.DEDUPE_OLDEST_CREATED
259+
| OnMultiple.DEDUPE_NEWEST_CREATED
260+
| OnMultiple.DEDUPE_OLDEST_MODIFIED
261+
| OnMultiple.DEDUPE_NEWEST_MODIFIED
262+
):
253263
# first result is the one to keep (already sorted)
254264
keep_uid = results[0][BubbleField.ID]
255265
delete_uids = [r[BubbleField.ID] for r in results[1:]]

src/bubble_data_api_client/types.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ class OnMultiple(StrEnum):
2424
ERROR = "error"
2525
UPDATE_ALL = "update_all"
2626
UPDATE_FIRST = "update_first"
27-
DEDUPE_OLDEST = "dedupe_oldest"
28-
DEDUPE_NEWEST = "dedupe_newest"
27+
DEDUPE_OLDEST_CREATED = "dedupe_oldest_created"
28+
DEDUPE_NEWEST_CREATED = "dedupe_newest_created"
29+
DEDUPE_OLDEST_MODIFIED = "dedupe_oldest_modified"
30+
DEDUPE_NEWEST_MODIFIED = "dedupe_newest_modified"
2931

3032

3133
class CreateOrUpdateResult(TypedDict):

src/tests/unit/client/test_create_or_update.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ async def test_create_or_update_update_all(configured_client: None) -> None:
176176

177177

178178
@respx.mock
179-
async def test_create_or_update_dedupe_oldest(configured_client: None) -> None:
180-
"""Test that DEDUPE_OLDEST keeps oldest and deletes others."""
179+
async def test_create_or_update_dedupe_oldest_created(configured_client: None) -> None:
180+
"""Test that DEDUPE_OLDEST_CREATED keeps oldest by created date and deletes others."""
181181
# find returns results sorted by Created Date ascending (oldest first)
182182
respx.get("https://test.example.com/customer").mock(
183183
return_value=httpx.Response(
@@ -200,7 +200,7 @@ async def test_create_or_update_dedupe_oldest(configured_client: None) -> None:
200200
typename="customer",
201201
match={"external_id": "abc"},
202202
data={"name": "John"},
203-
on_multiple=OnMultiple.DEDUPE_OLDEST,
203+
on_multiple=OnMultiple.DEDUPE_OLDEST_CREATED,
204204
)
205205

206206
assert result["created"] is False
@@ -211,8 +211,8 @@ async def test_create_or_update_dedupe_oldest(configured_client: None) -> None:
211211

212212

213213
@respx.mock
214-
async def test_create_or_update_dedupe_newest(configured_client: None) -> None:
215-
"""Test that DEDUPE_NEWEST keeps newest and deletes others."""
214+
async def test_create_or_update_dedupe_newest_created(configured_client: None) -> None:
215+
"""Test that DEDUPE_NEWEST_CREATED keeps newest by created date and deletes others."""
216216
# find returns results sorted by Created Date descending (newest first)
217217
respx.get("https://test.example.com/customer").mock(
218218
return_value=httpx.Response(
@@ -235,7 +235,7 @@ async def test_create_or_update_dedupe_newest(configured_client: None) -> None:
235235
typename="customer",
236236
match={"external_id": "abc"},
237237
data={"name": "John"},
238-
on_multiple=OnMultiple.DEDUPE_NEWEST,
238+
on_multiple=OnMultiple.DEDUPE_NEWEST_CREATED,
239239
)
240240

241241
assert result["created"] is False
@@ -245,6 +245,76 @@ async def test_create_or_update_dedupe_newest(configured_client: None) -> None:
245245
assert update_newest.call_count == 1
246246

247247

248+
@respx.mock
249+
async def test_create_or_update_dedupe_oldest_modified(configured_client: None) -> None:
250+
"""Test that DEDUPE_OLDEST_MODIFIED keeps oldest by modified date and deletes others."""
251+
# find returns results sorted by Modified Date ascending (least recently modified first)
252+
respx.get("https://test.example.com/customer").mock(
253+
return_value=httpx.Response(
254+
200,
255+
json={
256+
"response": {
257+
"results": [{"_id": "oldest_mod"}, {"_id": "newer_mod"}, {"_id": "newest_mod"}],
258+
"count": 3,
259+
"remaining": 0,
260+
}
261+
},
262+
)
263+
)
264+
delete_newer = respx.delete("https://test.example.com/customer/newer_mod").mock(return_value=httpx.Response(204))
265+
delete_newest = respx.delete("https://test.example.com/customer/newest_mod").mock(return_value=httpx.Response(204))
266+
update_oldest = respx.patch("https://test.example.com/customer/oldest_mod").mock(return_value=httpx.Response(204))
267+
268+
async with RawClient() as client:
269+
result = await client.create_or_update(
270+
typename="customer",
271+
match={"external_id": "abc"},
272+
data={"name": "John"},
273+
on_multiple=OnMultiple.DEDUPE_OLDEST_MODIFIED,
274+
)
275+
276+
assert result["created"] is False
277+
assert result["uids"] == ["oldest_mod"]
278+
assert delete_newer.call_count == 1
279+
assert delete_newest.call_count == 1
280+
assert update_oldest.call_count == 1
281+
282+
283+
@respx.mock
284+
async def test_create_or_update_dedupe_newest_modified(configured_client: None) -> None:
285+
"""Test that DEDUPE_NEWEST_MODIFIED keeps newest by modified date and deletes others."""
286+
# find returns results sorted by Modified Date descending (most recently modified first)
287+
respx.get("https://test.example.com/customer").mock(
288+
return_value=httpx.Response(
289+
200,
290+
json={
291+
"response": {
292+
"results": [{"_id": "newest_mod"}, {"_id": "newer_mod"}, {"_id": "oldest_mod"}],
293+
"count": 3,
294+
"remaining": 0,
295+
}
296+
},
297+
)
298+
)
299+
delete_newer = respx.delete("https://test.example.com/customer/newer_mod").mock(return_value=httpx.Response(204))
300+
delete_oldest = respx.delete("https://test.example.com/customer/oldest_mod").mock(return_value=httpx.Response(204))
301+
update_newest = respx.patch("https://test.example.com/customer/newest_mod").mock(return_value=httpx.Response(204))
302+
303+
async with RawClient() as client:
304+
result = await client.create_or_update(
305+
typename="customer",
306+
match={"external_id": "abc"},
307+
data={"name": "John"},
308+
on_multiple=OnMultiple.DEDUPE_NEWEST_MODIFIED,
309+
)
310+
311+
assert result["created"] is False
312+
assert result["uids"] == ["newest_mod"]
313+
assert delete_newer.call_count == 1
314+
assert delete_oldest.call_count == 1
315+
assert update_newest.call_count == 1
316+
317+
248318
async def test_create_or_update_invalid_on_multiple(configured_client: None) -> None:
249319
"""Test that invalid on_multiple raises InvalidOnMultipleError."""
250320
async with RawClient() as client:
@@ -348,7 +418,7 @@ async def test_create_or_update_dedupe_partial_delete_failure(configured_client:
348418
typename="customer",
349419
match={"external_id": "abc"},
350420
data={"name": "John"},
351-
on_multiple=OnMultiple.DEDUPE_OLDEST,
421+
on_multiple=OnMultiple.DEDUPE_OLDEST_CREATED,
352422
)
353423

354424
error = exc_info.value

0 commit comments

Comments
 (0)