Skip to content

Commit fa16258

Browse files
author
Samson Gebre
committed
feat: implement batch upsert operation and related methods
1 parent 7ce015e commit fa16258

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

src/PowerPlatform/Dataverse/data/_batch.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
CascadeConfiguration,
2222
)
2323
from ..models.labels import Label, LocalizedLabel
24+
from ..models.upsert import UpsertItem
2425
from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK
2526
from ._raw_request import _RawRequest
2627

@@ -71,6 +72,12 @@ class _RecordGet:
7172
select: Optional[List[str]] = None
7273

7374

75+
@dataclass
76+
class _RecordUpsert:
77+
table: str
78+
items: List[UpsertItem] # always non-empty; normalised by BatchRecordOperations
79+
80+
7481
# --- Table intent types ---
7582

7683

@@ -287,6 +294,8 @@ def _resolve_item(self, item: Any) -> List[_RawRequest]:
287294
return self._resolve_record_delete(item)
288295
if isinstance(item, _RecordGet):
289296
return self._resolve_record_get(item)
297+
if isinstance(item, _RecordUpsert):
298+
return self._resolve_record_upsert(item)
290299
if isinstance(item, _TableCreate):
291300
return self._resolve_table_create(item)
292301
if isinstance(item, _TableDelete):
@@ -361,6 +370,15 @@ def _resolve_record_delete(self, op: _RecordDelete) -> List[_RawRequest]:
361370
def _resolve_record_get(self, op: _RecordGet) -> List[_RawRequest]:
362371
return [self._od._build_get(op.table, op.record_id, select=op.select)]
363372

373+
def _resolve_record_upsert(self, op: _RecordUpsert) -> List[_RawRequest]:
374+
entity_set = self._od._entity_set_from_schema_name(op.table)
375+
if len(op.items) == 1:
376+
item = op.items[0]
377+
return [self._od._build_upsert(entity_set, op.table, item.alternate_key, item.record)]
378+
alternate_keys = [i.alternate_key for i in op.items]
379+
records = [i.record for i in op.items]
380+
return [self._od._build_upsert_multiple(entity_set, op.table, alternate_keys, records)]
381+
364382
# ------------------------------------------------------------------
365383
# Table resolvers — delegate to _ODataClient._build_* methods
366384
# (pre-resolution GETs for MetadataId remain here; they are batch-

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,60 @@ def _build_update_multiple(
17041704
raise ValidationError("changes must be a dict or list[dict].", subcode="invalid_changes_type")
17051705
return self._build_update_multiple_from_records(entity_set, table, records)
17061706

1707+
def _build_upsert(
1708+
self,
1709+
entity_set: str,
1710+
table: str,
1711+
alternate_key: Dict[str, Any],
1712+
record: Dict[str, Any],
1713+
) -> _RawRequest:
1714+
"""Build a single-record PATCH upsert request without sending it.
1715+
1716+
Unlike :meth:`_build_update`, no ``If-Match: *`` header is added so the
1717+
server creates the record when it does not yet exist.
1718+
"""
1719+
body = self._lowercase_keys(record)
1720+
body = self._convert_labels_to_ints(table, body)
1721+
key_str = self._build_alternate_key_str(alternate_key)
1722+
url = f"{self.api}/{entity_set}({key_str})"
1723+
return _RawRequest(
1724+
method="PATCH",
1725+
url=url,
1726+
body=json.dumps(body, ensure_ascii=False),
1727+
)
1728+
1729+
def _build_upsert_multiple(
1730+
self,
1731+
entity_set: str,
1732+
table: str,
1733+
alternate_keys: List[Dict[str, Any]],
1734+
records: List[Dict[str, Any]],
1735+
) -> _RawRequest:
1736+
"""Build an UpsertMultiple POST request without sending it."""
1737+
if len(alternate_keys) != len(records):
1738+
raise ValidationError(
1739+
f"alternate_keys and records must have the same length "
1740+
f"({len(alternate_keys)} != {len(records)})",
1741+
subcode="upsert_length_mismatch",
1742+
)
1743+
logical_name = table.lower()
1744+
targets: List[Dict[str, Any]] = []
1745+
for alt_key, record in zip(alternate_keys, records):
1746+
alt_key_lower = self._lowercase_keys(alt_key)
1747+
record_processed = self._lowercase_keys(record)
1748+
record_processed = self._convert_labels_to_ints(table, record_processed)
1749+
combined: Dict[str, Any] = {**alt_key_lower, **record_processed}
1750+
if "@odata.type" not in combined:
1751+
combined["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}"
1752+
key_str = self._build_alternate_key_str(alt_key)
1753+
combined["@odata.id"] = f"{entity_set}({key_str})"
1754+
targets.append(combined)
1755+
return _RawRequest(
1756+
method="POST",
1757+
url=f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpsertMultiple",
1758+
body=json.dumps({"Targets": targets}, ensure_ascii=False),
1759+
)
1760+
17071761
def _build_delete(
17081762
self,
17091763
table: str,

src/PowerPlatform/Dataverse/operations/batch.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
_RecordUpdate,
1616
_RecordDelete,
1717
_RecordGet,
18+
_RecordUpsert,
1819
_TableCreate,
1920
_TableDelete,
2021
_TableGet,
@@ -29,6 +30,7 @@
2930
_QuerySql,
3031
)
3132
from ..models.batch import BatchResult
33+
from ..models.upsert import UpsertItem
3234
from ..models.relationship import (
3335
LookupAttributeMetadata,
3436
OneToManyRelationshipMetadata,
@@ -241,6 +243,59 @@ def get(
241243
"""
242244
self._batch._items.append(_RecordGet(table=table, record_id=record_id, select=select))
243245

246+
def upsert(
247+
self,
248+
table: str,
249+
items: List[Union[UpsertItem, Dict[str, Any]]],
250+
) -> None:
251+
"""
252+
Add an upsert operation to the batch.
253+
254+
Mirrors :meth:`~PowerPlatform.Dataverse.operations.records.RecordOperations.upsert`
255+
exactly: a single item becomes a PATCH request using the alternate key; multiple
256+
items become one ``UpsertMultiple`` POST.
257+
258+
Each item must be a :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
259+
or a plain ``dict`` with ``"alternate_key"`` and ``"record"`` keys (both dicts).
260+
261+
:param table: Table schema name (e.g. ``"account"``).
262+
:param items: Non-empty list of :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
263+
instances or equivalent dicts.
264+
265+
Example::
266+
267+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
268+
269+
batch.records.upsert("account", [
270+
UpsertItem(
271+
alternate_key={"accountnumber": "ACC-001"},
272+
record={"name": "Contoso Ltd"},
273+
),
274+
UpsertItem(
275+
alternate_key={"accountnumber": "ACC-002"},
276+
record={"name": "Fabrikam Inc"},
277+
),
278+
])
279+
"""
280+
if not isinstance(items, list) or not items:
281+
raise ValidationError("items must be a non-empty list", subcode="VALIDATION_UPSERT_EMPTY")
282+
normalized: List[UpsertItem] = []
283+
for i in items:
284+
if isinstance(i, UpsertItem):
285+
normalized.append(i)
286+
elif (
287+
isinstance(i, dict)
288+
and isinstance(i.get("alternate_key"), dict)
289+
and isinstance(i.get("record"), dict)
290+
):
291+
normalized.append(UpsertItem(alternate_key=i["alternate_key"], record=i["record"]))
292+
else:
293+
raise ValidationError(
294+
"Each upsert item must be a UpsertItem or a dict with 'alternate_key' and 'record' keys.",
295+
subcode="VALIDATION_UPSERT_ITEM",
296+
)
297+
self._batch._items.append(_RecordUpsert(table=table, items=normalized))
298+
244299

245300
class BatchTableOperations:
246301
"""

tests/unit/data/test_batch_serialization.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
_RecordCreate,
1515
_RecordDelete,
1616
_RecordGet,
17+
_RecordUpsert,
1718
_TableGet,
1819
_TableList,
1920
_QuerySql,
@@ -23,6 +24,7 @@
2324
_parse_http_response_part,
2425
_CRLF,
2526
)
27+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
2628
from PowerPlatform.Dataverse.data._raw_request import _RawRequest
2729
from PowerPlatform.Dataverse.models.batch import BatchItemResponse, BatchResult
2830

@@ -337,5 +339,124 @@ def test_operations_in_order(self):
337339
self.assertIsInstance(cs.operations[1], _RecordDelete)
338340

339341

342+
class TestResolveBatchUpsert(unittest.TestCase):
343+
"""Tests that _BatchClient._resolve_record_upsert calls the correct _build_* methods."""
344+
345+
def _client_and_od(self):
346+
od = _make_od()
347+
od._entity_set_from_schema_name.return_value = "accounts"
348+
client = _BatchClient(od)
349+
return client, od
350+
351+
def test_resolve_single_item_calls_build_upsert(self):
352+
client, od = self._client_and_od()
353+
mock_req = MagicMock()
354+
od._build_upsert.return_value = mock_req
355+
356+
item = UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso"})
357+
op = _RecordUpsert(table="account", items=[item])
358+
result = client._resolve_record_upsert(op)
359+
360+
od._build_upsert.assert_called_once_with(
361+
"accounts", "account", {"accountnumber": "ACC-001"}, {"name": "Contoso"}
362+
)
363+
self.assertEqual(result, [mock_req])
364+
365+
def test_resolve_multiple_items_calls_build_upsert_multiple(self):
366+
client, od = self._client_and_od()
367+
mock_req = MagicMock()
368+
od._build_upsert_multiple.return_value = mock_req
369+
370+
items = [
371+
UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso"}),
372+
UpsertItem(alternate_key={"accountnumber": "ACC-002"}, record={"name": "Fabrikam"}),
373+
]
374+
op = _RecordUpsert(table="account", items=items)
375+
result = client._resolve_record_upsert(op)
376+
377+
od._build_upsert_multiple.assert_called_once_with(
378+
"accounts",
379+
"account",
380+
[{"accountnumber": "ACC-001"}, {"accountnumber": "ACC-002"}],
381+
[{"name": "Contoso"}, {"name": "Fabrikam"}],
382+
)
383+
self.assertEqual(result, [mock_req])
384+
385+
def test_resolve_item_dispatch_routes_to_upsert(self):
386+
client, od = self._client_and_od()
387+
mock_req = MagicMock()
388+
od._build_upsert.return_value = mock_req
389+
390+
item = UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso"})
391+
op = _RecordUpsert(table="account", items=[item])
392+
result = client._resolve_item(op)
393+
394+
self.assertEqual(result, [mock_req])
395+
396+
397+
class TestBatchRecordOperationsUpsert(unittest.TestCase):
398+
"""Tests for BatchRecordOperations.upsert (operations/batch.py)."""
399+
400+
def _make_batch(self):
401+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
402+
403+
batch = MagicMock()
404+
batch._items = []
405+
return BatchRecordOperations(batch), batch
406+
407+
def test_upsert_single_upsert_item_appended(self):
408+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
409+
410+
rec_ops, batch = self._make_batch()
411+
item = UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso"})
412+
rec_ops.upsert("account", [item])
413+
414+
self.assertEqual(len(batch._items), 1)
415+
intent = batch._items[0]
416+
self.assertIsInstance(intent, _RecordUpsert)
417+
self.assertEqual(intent.table, "account")
418+
self.assertEqual(len(intent.items), 1)
419+
self.assertEqual(intent.items[0].alternate_key, {"accountnumber": "ACC-001"})
420+
421+
def test_upsert_plain_dict_normalised_to_upsert_item(self):
422+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
423+
424+
rec_ops, batch = self._make_batch()
425+
rec_ops.upsert("account", [{"alternate_key": {"accountnumber": "X"}, "record": {"name": "Y"}}])
426+
427+
intent = batch._items[0]
428+
self.assertIsInstance(intent.items[0], UpsertItem)
429+
self.assertEqual(intent.items[0].record, {"name": "Y"})
430+
431+
def test_upsert_empty_list_raises(self):
432+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
433+
from PowerPlatform.Dataverse.core.errors import ValidationError
434+
435+
rec_ops, _ = self._make_batch()
436+
with self.assertRaises(ValidationError):
437+
rec_ops.upsert("account", [])
438+
439+
def test_upsert_invalid_item_raises(self):
440+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
441+
from PowerPlatform.Dataverse.core.errors import ValidationError
442+
443+
rec_ops, _ = self._make_batch()
444+
with self.assertRaises(ValidationError):
445+
rec_ops.upsert("account", ["not_a_valid_item"])
446+
447+
def test_upsert_multiple_items_all_normalised(self):
448+
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
449+
450+
rec_ops, batch = self._make_batch()
451+
rec_ops.upsert("account", [
452+
UpsertItem(alternate_key={"accountnumber": "A"}, record={"name": "Alpha"}),
453+
UpsertItem(alternate_key={"accountnumber": "B"}, record={"name": "Beta"}),
454+
])
455+
456+
intent = batch._items[0]
457+
self.assertEqual(len(intent.items), 2)
458+
self.assertEqual(intent.items[1].alternate_key, {"accountnumber": "B"})
459+
460+
340461
if __name__ == "__main__":
341462
unittest.main()

0 commit comments

Comments
 (0)