Skip to content

Commit 606e31b

Browse files
author
Samson Gebre
committed
feat: implement upsert and upsertmultiple functionality with alternate key support
1 parent 6b8353b commit 606e31b

5 files changed

Lines changed: 522 additions & 0 deletions

File tree

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,110 @@ def _create_multiple(self, entity_set: str, table_schema_name: str, records: Lis
352352
return out
353353
return []
354354

355+
def _build_alternate_key_str(self, alternate_key: Dict[str, Any]) -> str:
356+
"""Build an OData alternate key segment from a mapping of key names to values.
357+
358+
String values are single-quoted and escaped; all other values are rendered as-is.
359+
360+
:param alternate_key: Mapping of alternate key attribute names to their values.
361+
:type alternate_key: ``dict[str, Any]``
362+
363+
:return: Comma-separated key=value pairs suitable for use in a URL segment.
364+
:rtype: ``str``
365+
"""
366+
parts = []
367+
for k, v in alternate_key.items():
368+
k_lower = k.lower() if isinstance(k, str) else k
369+
if isinstance(v, str):
370+
v_escaped = self._escape_odata_quotes(v)
371+
parts.append(f"{k_lower}='{v_escaped}'")
372+
else:
373+
parts.append(f"{k_lower}={v}")
374+
return ",".join(parts)
375+
376+
def _upsert(
377+
self,
378+
entity_set: str,
379+
table_schema_name: str,
380+
alternate_key: Dict[str, Any],
381+
record: Dict[str, Any],
382+
) -> None:
383+
"""Upsert a single record using an alternate key.
384+
385+
Issues a PATCH request to ``{entity_set}({key_pairs})`` where ``key_pairs``
386+
is the OData alternate key segment built from ``alternate_key``. Creates the
387+
record if it does not exist; updates it if it does.
388+
389+
:param entity_set: Resolved entity set (plural) name.
390+
:type entity_set: ``str``
391+
:param table_schema_name: Schema name of the table.
392+
:type table_schema_name: ``str``
393+
:param alternate_key: Mapping of alternate key attribute names to their values
394+
used to identify the target record in the URL.
395+
:type alternate_key: ``dict[str, Any]``
396+
:param record: Attribute payload to set on the record.
397+
:type record: ``dict[str, Any]``
398+
399+
:return: ``None``
400+
:rtype: ``None``
401+
"""
402+
record = self._lowercase_keys(record)
403+
record = self._convert_labels_to_ints(table_schema_name, record)
404+
key_str = self._build_alternate_key_str(alternate_key)
405+
url = f"{self.api}/{entity_set}({key_str})"
406+
self._request("patch", url, json=record, expected=(200, 201, 204))
407+
408+
def _upsert_multiple(
409+
self,
410+
entity_set: str,
411+
table_schema_name: str,
412+
alternate_keys: List[Dict[str, Any]],
413+
records: List[Dict[str, Any]],
414+
) -> None:
415+
"""Upsert multiple records using the collection-bound ``UpsertMultiple`` action.
416+
417+
Each target is formed by merging the corresponding alternate key fields and record
418+
fields. The ``@odata.type`` annotation is injected automatically if absent.
419+
420+
:param entity_set: Resolved entity set (plural) name.
421+
:type entity_set: ``str``
422+
:param table_schema_name: Schema name of the table.
423+
:type table_schema_name: ``str``
424+
:param alternate_keys: List of alternate key dictionaries, one per record.
425+
Order is significant: ``alternate_keys[i]`` must correspond to ``records[i]``.
426+
Python ``list`` preserves insertion order, so the correspondence is guaranteed
427+
as long as both lists are built from the same source in the same order.
428+
:type alternate_keys: ``list[dict[str, Any]]``
429+
:param records: List of record payload dictionaries, one per record.
430+
Must be the same length as ``alternate_keys``.
431+
:type records: ``list[dict[str, Any]]``
432+
433+
:return: ``None``
434+
:rtype: ``None``
435+
436+
:raises ValueError: If ``alternate_keys`` and ``records`` differ in length.
437+
"""
438+
if len(alternate_keys) != len(records):
439+
raise ValueError(
440+
f"alternate_keys and records must have the same length " f"({len(alternate_keys)} != {len(records)})"
441+
)
442+
logical_name = table_schema_name.lower()
443+
targets: List[Dict[str, Any]] = []
444+
for alt_key, record in zip(alternate_keys, records):
445+
combined: Dict[str, Any] = {}
446+
combined.update(self._lowercase_keys(alt_key))
447+
record_processed = self._lowercase_keys(record)
448+
record_processed = self._convert_labels_to_ints(table_schema_name, record_processed)
449+
combined.update(record_processed)
450+
if "@odata.type" not in combined:
451+
combined["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}"
452+
key_str = self._build_alternate_key_str(alt_key)
453+
combined["@odata.id"] = f"{entity_set}({key_str})"
454+
targets.append(combined)
455+
payload = {"Targets": targets}
456+
url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpsertMultiple"
457+
self._request("post", url, json=payload, expected=(200, 201, 204))
458+
355459
# --- Derived helpers for high-level client ergonomics ---
356460
def _primary_id_attr(self, table_schema_name: str) -> str:
357461
"""Return primary key attribute using metadata; error if unavailable."""
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Upsert data models for the Dataverse SDK."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
from typing import Any, Dict
10+
11+
__all__ = ["UpsertItem"]
12+
13+
14+
@dataclass
15+
class UpsertItem:
16+
"""Represents a single upsert operation targeting a record by its alternate key.
17+
18+
Used with :meth:`~PowerPlatform.Dataverse.operations.records.RecordOperations.upsert`
19+
to upsert one or more records identified by alternate keys rather than primary GUIDs.
20+
21+
:param alternate_key: Dictionary mapping alternate key attribute names to their values.
22+
String values are automatically quoted and escaped in the OData URL. Integer and
23+
other non-string values are included without quotes.
24+
:type alternate_key: dict[str, Any]
25+
:param record: Dictionary of attribute names to values for the record payload.
26+
Keys are automatically lowercased. Picklist labels are resolved to integer option
27+
values when a matching option set is found.
28+
:type record: dict[str, Any]
29+
30+
Example::
31+
32+
item = UpsertItem(
33+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
34+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
35+
)
36+
"""
37+
38+
alternate_key: Dict[str, Any]
39+
record: Dict[str, Any]

src/PowerPlatform/Dataverse/operations/records.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from typing import Any, Dict, List, Optional, Union, overload, TYPE_CHECKING
99

10+
from ..models.upsert import UpsertItem
11+
1012
if TYPE_CHECKING:
1113
from ..client import DataverseClient
1214

@@ -256,3 +258,102 @@ def get(
256258
raise TypeError("record_id must be str")
257259
with self._client._scoped_odata() as od:
258260
return od._get(table, record_id, select=select)
261+
262+
# ------------------------------------------------------------------ upsert
263+
264+
def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) -> None:
265+
"""Upsert one or more records identified by alternate keys.
266+
267+
When ``items`` contains a single entry, performs a single upsert via PATCH
268+
using the alternate key in the URL. When ``items`` contains multiple entries,
269+
uses the ``UpsertMultiple`` bulk action.
270+
271+
Each item must be either a :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
272+
or a plain ``dict`` with ``"alternate_key"`` and ``"record"`` keys (both dicts).
273+
274+
:param table: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``).
275+
:type table: str
276+
:param items: Non-empty list of :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
277+
instances or dicts with ``"alternate_key"`` and ``"record"`` keys.
278+
:type items: list[UpsertItem | dict]
279+
280+
:return: ``None``
281+
:rtype: None
282+
283+
:raises TypeError: If ``items`` is not a non-empty list, or if any element is
284+
neither a :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem` nor a
285+
dict with ``"alternate_key"`` and ``"record"`` keys.
286+
287+
Example:
288+
Upsert a single record using ``UpsertItem``::
289+
290+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
291+
292+
client.records.upsert("account", [
293+
UpsertItem(
294+
alternate_key={"accountnumber": "ACC-001"},
295+
record={"name": "Contoso Ltd", "description": "Primary account"},
296+
)
297+
])
298+
299+
Upsert a single record using a plain dict::
300+
301+
client.records.upsert("account", [
302+
{
303+
"alternate_key": {"accountnumber": "ACC-001"},
304+
"record": {"name": "Contoso Ltd", "description": "Primary account"},
305+
},
306+
])
307+
308+
Upsert multiple records using ``UpsertItem``::
309+
310+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
311+
312+
client.records.upsert("account", [
313+
UpsertItem(
314+
alternate_key={"accountnumber": "ACC-001"},
315+
record={"name": "Contoso Ltd", "description": "Primary account"},
316+
),
317+
UpsertItem(
318+
alternate_key={"accountnumber": "ACC-002"},
319+
record={"name": "Fabrikam Inc", "description": "Partner account"},
320+
),
321+
])
322+
323+
Upsert multiple records using plain dicts::
324+
325+
client.records.upsert("account", [
326+
{
327+
"alternate_key": {"accountnumber": "ACC-001"},
328+
"record": {"name": "Contoso Ltd", "description": "Primary account"},
329+
},
330+
{
331+
"alternate_key": {"accountnumber": "ACC-002"},
332+
"record": {"name": "Fabrikam Inc", "description": "Partner account"},
333+
},
334+
])
335+
336+
The ``alternate_key`` dict may contain multiple columns when the configured
337+
alternate key is composite, e.g.
338+
``{"accountnumber": "ACC-001", "address1_postalcode": "98052"}``.
339+
"""
340+
if not isinstance(items, list) or not items:
341+
raise TypeError("items must be a non-empty list of UpsertItem or dicts")
342+
normalized: List[UpsertItem] = []
343+
for i in items:
344+
if isinstance(i, UpsertItem):
345+
normalized.append(i)
346+
elif isinstance(i, dict) and isinstance(i.get("alternate_key"), dict) and isinstance(i.get("record"), dict):
347+
normalized.append(UpsertItem(alternate_key=i["alternate_key"], record=i["record"]))
348+
else:
349+
raise TypeError("Each item must be a UpsertItem or a dict with 'alternate_key' and 'record' keys")
350+
with self._client._scoped_odata() as od:
351+
entity_set = od._entity_set_from_schema_name(table)
352+
if len(normalized) == 1:
353+
item = normalized[0]
354+
od._upsert(entity_set, table, item.alternate_key, item.record)
355+
else:
356+
alternate_keys = [i.alternate_key for i in normalized]
357+
records = [i.record for i in normalized]
358+
od._upsert_multiple(entity_set, table, alternate_keys, records)
359+
return None

0 commit comments

Comments
 (0)