Skip to content

Commit 4a828ac

Browse files
sagebreeSamson Gebreclaude
authored
feat: upsert and upsertmultiple functionality with alternate key support (#106)
* feat: implement upsert and upsertmultiple functionality with alternate key support * enhance upsert validation and add unit tests for alternate key handling * fix format with black * fix: apply black formatting to records.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add upsert usage to README and SKILL.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add upsert section to README and SKILL.md with prerequisite note Documents client.records.upsert() usage including single, bulk, composite key, and plain dict (no-import) examples. Adds prerequisite note that an alternate key must be configured in Dataverse before upsert will work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Samson Gebre <sagebree@microsoft.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4dad4bd commit 4a828ac

8 files changed

Lines changed: 700 additions & 1 deletion

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@ client.records.update("account", account_id, {"telephone1": "555-0200"})
107107
client.records.update("account", [id1, id2, id3], {"industry": "Technology"})
108108
```
109109

110+
#### Upsert Records
111+
Creates or updates records identified by alternate keys. Single item → PATCH; multiple items → `UpsertMultiple` bulk action.
112+
> **Prerequisite**: The table must have an alternate key configured in Dataverse for the columns used in `alternate_key`. Without it, Dataverse will reject the request with a 400 error.
113+
```python
114+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
115+
116+
# Single upsert
117+
client.records.upsert("account", [
118+
UpsertItem(
119+
alternate_key={"accountnumber": "ACC-001"},
120+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
121+
)
122+
])
123+
124+
# Bulk upsert (uses UpsertMultiple API automatically)
125+
client.records.upsert("account", [
126+
UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso Ltd"}),
127+
UpsertItem(alternate_key={"accountnumber": "ACC-002"}, record={"name": "Fabrikam Inc"}),
128+
])
129+
130+
# Composite alternate key
131+
client.records.upsert("account", [
132+
UpsertItem(
133+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
134+
record={"name": "Contoso Ltd"},
135+
)
136+
])
137+
138+
# Plain dict syntax (no import needed)
139+
client.records.upsert("account", [
140+
{"alternate_key": {"accountnumber": "ACC-001"}, "record": {"name": "Contoso Ltd"}}
141+
])
142+
```
143+
110144
#### Delete Records
111145
```python
112146
# Single delete

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
2323
- [Quick start](#quick-start)
2424
- [Basic CRUD operations](#basic-crud-operations)
2525
- [Bulk operations](#bulk-operations)
26+
- [Upsert operations](#upsert-operations)
2627
- [Query data](#query-data)
2728
- [Table management](#table-management)
2829
- [Relationship management](#relationship-management)
@@ -34,7 +35,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
3435
## Key features
3536

3637
- **🔄 CRUD Operations**: Create, read, update, and delete records with support for bulk operations and automatic retry
37-
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
38+
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, `UpsertMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
3839
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
3940
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
4041
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
@@ -178,6 +179,57 @@ client.records.update("account", ids, {"industry": "Technology"})
178179
client.records.delete("account", ids, use_bulk_delete=True)
179180
```
180181

182+
### Upsert operations
183+
184+
Use `client.records.upsert()` to create or update records identified by alternate keys. When the
185+
key matches an existing record it is updated; otherwise the record is created. A single item uses
186+
a PATCH request; multiple items use the `UpsertMultiple` bulk action.
187+
188+
> **Prerequisite**: The table must have an **alternate key** configured in Dataverse for the
189+
> columns used in `alternate_key`. Alternate keys are defined in the table's metadata (Power Apps
190+
> maker portal → Table → Keys, or via the Dataverse API). Without a configured alternate key,
191+
> upsert requests will be rejected by Dataverse with a 400 error.
192+
193+
```python
194+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
195+
196+
# Upsert a single record
197+
client.records.upsert("account", [
198+
UpsertItem(
199+
alternate_key={"accountnumber": "ACC-001"},
200+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
201+
)
202+
])
203+
204+
# Upsert multiple records (uses UpsertMultiple bulk action)
205+
client.records.upsert("account", [
206+
UpsertItem(
207+
alternate_key={"accountnumber": "ACC-001"},
208+
record={"name": "Contoso Ltd"},
209+
),
210+
UpsertItem(
211+
alternate_key={"accountnumber": "ACC-002"},
212+
record={"name": "Fabrikam Inc"},
213+
),
214+
])
215+
216+
# Composite alternate key (multiple columns identify the record)
217+
client.records.upsert("account", [
218+
UpsertItem(
219+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
220+
record={"name": "Contoso Ltd"},
221+
)
222+
])
223+
224+
# Plain dict syntax (no import needed)
225+
client.records.upsert("account", [
226+
{
227+
"alternate_key": {"accountnumber": "ACC-001"},
228+
"record": {"name": "Contoso Ltd"},
229+
}
230+
])
231+
```
232+
181233
### Query data
182234

183235
```python

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@ client.records.update("account", account_id, {"telephone1": "555-0200"})
107107
client.records.update("account", [id1, id2, id3], {"industry": "Technology"})
108108
```
109109

110+
#### Upsert Records
111+
Creates or updates records identified by alternate keys. Single item → PATCH; multiple items → `UpsertMultiple` bulk action.
112+
> **Prerequisite**: The table must have an alternate key configured in Dataverse for the columns used in `alternate_key`. Without it, Dataverse will reject the request with a 400 error.
113+
```python
114+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
115+
116+
# Single upsert
117+
client.records.upsert("account", [
118+
UpsertItem(
119+
alternate_key={"accountnumber": "ACC-001"},
120+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
121+
)
122+
])
123+
124+
# Bulk upsert (uses UpsertMultiple API automatically)
125+
client.records.upsert("account", [
126+
UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso Ltd"}),
127+
UpsertItem(alternate_key={"accountnumber": "ACC-002"}, record={"name": "Fabrikam Inc"}),
128+
])
129+
130+
# Composite alternate key
131+
client.records.upsert("account", [
132+
UpsertItem(
133+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
134+
record={"name": "Contoso Ltd"},
135+
)
136+
])
137+
138+
# Plain dict syntax (no import needed)
139+
client.records.upsert("account", [
140+
{"alternate_key": {"accountnumber": "ACC-001"}, "record": {"name": "Contoso Ltd"}}
141+
])
142+
```
143+
110144
#### Delete Records
111145
```python
112146
# Single delete

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,124 @@ 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+
Must be a non-empty dict with string keys.
362+
:type alternate_key: ``dict[str, Any]``
363+
364+
:return: Comma-separated key=value pairs suitable for use in a URL segment.
365+
:rtype: ``str``
366+
367+
:raises ValueError: If ``alternate_key`` is empty.
368+
:raises TypeError: If any key in ``alternate_key`` is not a string.
369+
"""
370+
if not alternate_key:
371+
raise ValueError("alternate_key must be a non-empty dict")
372+
bad_keys = [k for k in alternate_key if not isinstance(k, str)]
373+
if bad_keys:
374+
raise TypeError(f"alternate_key keys must be strings; got: {bad_keys!r}")
375+
parts = []
376+
for k, v in alternate_key.items():
377+
k_lower = k.lower() if isinstance(k, str) else k
378+
if isinstance(v, str):
379+
v_escaped = self._escape_odata_quotes(v)
380+
parts.append(f"{k_lower}='{v_escaped}'")
381+
else:
382+
parts.append(f"{k_lower}={v}")
383+
return ",".join(parts)
384+
385+
def _upsert(
386+
self,
387+
entity_set: str,
388+
table_schema_name: str,
389+
alternate_key: Dict[str, Any],
390+
record: Dict[str, Any],
391+
) -> None:
392+
"""Upsert a single record using an alternate key.
393+
394+
Issues a PATCH request to ``{entity_set}({key_pairs})`` where ``key_pairs``
395+
is the OData alternate key segment built from ``alternate_key``. Creates the
396+
record if it does not exist; updates it if it does.
397+
398+
:param entity_set: Resolved entity set (plural) name.
399+
:type entity_set: ``str``
400+
:param table_schema_name: Schema name of the table.
401+
:type table_schema_name: ``str``
402+
:param alternate_key: Mapping of alternate key attribute names to their values
403+
used to identify the target record in the URL.
404+
:type alternate_key: ``dict[str, Any]``
405+
:param record: Attribute payload to set on the record.
406+
:type record: ``dict[str, Any]``
407+
408+
:return: ``None``
409+
:rtype: ``None``
410+
"""
411+
record = self._lowercase_keys(record)
412+
record = self._convert_labels_to_ints(table_schema_name, record)
413+
key_str = self._build_alternate_key_str(alternate_key)
414+
url = f"{self.api}/{entity_set}({key_str})"
415+
self._request("patch", url, json=record, expected=(200, 201, 204))
416+
417+
def _upsert_multiple(
418+
self,
419+
entity_set: str,
420+
table_schema_name: str,
421+
alternate_keys: List[Dict[str, Any]],
422+
records: List[Dict[str, Any]],
423+
) -> None:
424+
"""Upsert multiple records using the collection-bound ``UpsertMultiple`` action.
425+
426+
Each target is formed by merging the corresponding alternate key fields and record
427+
fields. The ``@odata.type`` annotation is injected automatically if absent.
428+
429+
:param entity_set: Resolved entity set (plural) name.
430+
:type entity_set: ``str``
431+
:param table_schema_name: Schema name of the table.
432+
:type table_schema_name: ``str``
433+
:param alternate_keys: List of alternate key dictionaries, one per record.
434+
Order is significant: ``alternate_keys[i]`` must correspond to ``records[i]``.
435+
Python ``list`` preserves insertion order, so the correspondence is guaranteed
436+
as long as both lists are built from the same source in the same order.
437+
:type alternate_keys: ``list[dict[str, Any]]``
438+
:param records: List of record payload dictionaries, one per record.
439+
Must be the same length as ``alternate_keys``.
440+
:type records: ``list[dict[str, Any]]``
441+
442+
:return: ``None``
443+
:rtype: ``None``
444+
445+
:raises ValueError: If ``alternate_keys`` and ``records`` differ in length, or if
446+
any record payload contains an alternate key field with a conflicting value.
447+
"""
448+
if len(alternate_keys) != len(records):
449+
raise ValueError(
450+
f"alternate_keys and records must have the same length " f"({len(alternate_keys)} != {len(records)})"
451+
)
452+
logical_name = table_schema_name.lower()
453+
targets: List[Dict[str, Any]] = []
454+
for alt_key, record in zip(alternate_keys, records):
455+
alt_key_lower = self._lowercase_keys(alt_key)
456+
record_processed = self._lowercase_keys(record)
457+
record_processed = self._convert_labels_to_ints(table_schema_name, record_processed)
458+
conflicting = {
459+
k for k in set(alt_key_lower) & set(record_processed) if alt_key_lower[k] != record_processed[k]
460+
}
461+
if conflicting:
462+
raise ValueError(f"record payload conflicts with alternate_key on fields: {sorted(conflicting)!r}")
463+
combined: Dict[str, Any] = {**alt_key_lower, **record_processed}
464+
if "@odata.type" not in combined:
465+
combined["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}"
466+
key_str = self._build_alternate_key_str(alt_key)
467+
combined["@odata.id"] = f"{entity_set}({key_str})"
468+
targets.append(combined)
469+
payload = {"Targets": targets}
470+
url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpsertMultiple"
471+
self._request("post", url, json=payload, expected=(200, 201, 204))
472+
355473
# --- Derived helpers for high-level client ergonomics ---
356474
def _primary_id_attr(self, table_schema_name: str) -> str:
357475
"""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]

0 commit comments

Comments
 (0)