Skip to content

Commit 28c45f8

Browse files
UpdateMultiple v1 (#8)
* Add update_multiple * cleanup --------- Co-authored-by: Tim Pellissier <tpellissier@microsoft.com>
1 parent e93fa6e commit 28c45f8

4 files changed

Lines changed: 142 additions & 1 deletion

File tree

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
55
- Read (SQL) — Execute read-only T‑SQL via the McpExecuteSqlQuery Custom API. Returns `list[dict]`.
66
- OData CRUD — Thin wrappers over Dataverse Web API (create/get/update/delete).
77
- Bulk create — Pass a list of records to `create(...)` to invoke the bound `CreateMultiple` action; returns `list[str]` of GUIDs. If `@odata.type` is absent the SDK resolves the logical name from metadata (cached).
8+
- Bulk update — Call `update_multiple(entity_set, records)` to invoke the bound `UpdateMultiple` action; returns nothing. Each record must include the real primary key attribute (e.g. `accountid`).
89
- Retrieve multiple (paging) — Generator-based `get_multiple(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`).
910
- Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes).
1011
- Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks.
@@ -16,6 +17,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
1617
- SQL-over-API: T-SQL routed through Custom API endpoint (no ODBC / TDS driver required).
1718
- Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them.
1819
- Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(entity_set, payloads)`; returns list of created IDs.
20+
- Bulk update via `UpdateMultiple` by calling `update_multiple(entity_set, records)` with primary key attribute present in each record; returns nothing.
1921
- Retrieve multiple with server-driven paging: `get_multiple(...)` yields lists (pages) following `@odata.nextLink`. Control total via `$top` and per-page via `page_size` (Prefer: `odata.maxpagesize`).
2022
- Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query.
2123

@@ -100,6 +102,13 @@ account = client.get("accounts", account_id)
100102
# Update (returns updated record)
101103
updated = client.update("accounts", account_id, {"telephone1": "555-0199"})
102104

105+
# Bulk update (collection-bound UpdateMultiple)
106+
# Each record must include the primary key attribute (accountid). The call returns None.
107+
client.update_multiple("accounts", [
108+
{"accountid": account_id, "telephone1": "555-0200"},
109+
])
110+
print({"bulk_update": "ok"})
111+
103112
# Delete
104113
client.delete("accounts", account_id)
105114

@@ -124,6 +133,29 @@ assert isinstance(ids, list) and all(isinstance(x, str) for x in ids)
124133
print({"created_ids": ids})
125134
```
126135

136+
## Bulk update (UpdateMultiple)
137+
138+
Use `update_multiple(entity_set, records)` for a transactional batch update. The method returns `None`.
139+
140+
```python
141+
ids = client.create("accounts", [
142+
{"name": "Fourth Coffee"},
143+
{"name": "Tailspin"},
144+
])
145+
146+
client.update_multiple("accounts", [
147+
{"accountid": ids[0], "telephone1": "555-1111"},
148+
{"accountid": ids[1], "telephone1": "555-2222"},
149+
])
150+
print({"bulk_update": "ok"})
151+
```
152+
153+
Notes:
154+
- Each record must include the primary key attribute (e.g. `accountid`). No `id` alias yet.
155+
- If any payload omits `@odata.type`, the logical name is resolved once and stamped (same as bulk create).
156+
- Entire request fails (HTTP error) if any individual update fails; no partial success list is returned.
157+
- If you need refreshed records post-update, issue individual `get` calls or a `get_multiple` query.
158+
127159
Notes:
128160
- The bulk create response typically includes IDs only; the SDK returns the list of GUID strings.
129161
- Single-record `create` still returns the full entity representation.
@@ -251,7 +283,7 @@ VS Code Tasks
251283

252284
## Limitations / Future Work
253285
- No general-purpose OData batching, upsert, or association operations yet.
254-
- `DeleteMultiple`/`UpdateMultiple` are not exposed; quickstart may demonstrate faster deletes using client-side concurrency only.
286+
- `DeleteMultiple` not yet exposed.
255287
- Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency.
256288
- Entity naming conventions in Dataverse: for multi-create the SDK resolves logical names from entity set metadata.
257289

examples/quickstart.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
286286
except Exception as e:
287287
print(f"Update/verify failed: {e}")
288288
sys.exit(1)
289+
290+
# 3.6) Bulk update (UpdateMultiple) demo: update count field on up to first 5 remaining records
291+
print("Bulk update (UpdateMultiple) demo:")
292+
try:
293+
if len(record_ids) > 1:
294+
# Prepare a small subset to update (skip the first already updated one)
295+
subset = record_ids[1:6]
296+
bulk_updates = []
297+
for idx, rid in enumerate(subset, start=1):
298+
# Simple deterministic changes so user can observe
299+
bulk_updates.append({
300+
id_key: rid,
301+
count_key: 100 + idx, # new count values
302+
})
303+
log_call(f"client.update_multiple('{entity_set}', <{len(bulk_updates)} records>)")
304+
# update_multiple returns nothing (fire-and-forget success semantics)
305+
backoff_retry(lambda: client.update_multiple(entity_set, bulk_updates))
306+
print({"bulk_update_requested": len(bulk_updates), "bulk_update_completed": True})
307+
# Verify the updated count values by refetching the subset
308+
verification = []
309+
# Small delay to reduce risk of any brief replication delay
310+
time.sleep(1)
311+
for rid in subset:
312+
rec = backoff_retry(lambda rid=rid: client.get(entity_set, rid))
313+
verification.append({
314+
"id": rid,
315+
"count": rec.get(count_key),
316+
})
317+
print({"bulk_update_verification": verification})
318+
else:
319+
print({"bulk_update_skipped": True, "reason": "not enough records"})
320+
except Exception as e:
321+
print(f"Bulk update failed: {e}")
322+
289323
# 4) Query records via SQL Custom API
290324
print("Query (SQL via Custom API):")
291325
try:

src/dataverse_sdk/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ def update(self, entity: str, record_id: str, record_data: dict) -> dict:
110110
"""
111111
return self._get_odata().update(entity, record_id, record_data)
112112

113+
def update_multiple(self, entity: str, records: List[Dict[str, Any]]) -> None:
114+
"""Bulk update multiple records via the bound UpdateMultiple action.
115+
116+
Parameters
117+
----------
118+
entity : str
119+
Entity set name (plural logical name).
120+
records : list[dict]
121+
Each record must include the primary key attribute (e.g. ``accountid``) plus fields to update.
122+
123+
Returns
124+
-------
125+
None
126+
On success returns nothing.
127+
"""
128+
self._get_odata().update_multiple(entity, records)
129+
return None
130+
113131
def delete(self, entity: str, record_id: str) -> None:
114132
"""Delete a record by ID.
115133

src/dataverse_sdk/odata.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,63 @@ def update(self, entity_set: str, key: str, data: Dict[str, Any]) -> Dict[str, A
210210
r.raise_for_status()
211211
return r.json()
212212

213+
def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> None:
214+
"""Bulk update existing records via the collection-bound UpdateMultiple action.
215+
216+
Parameters
217+
----------
218+
entity_set : str
219+
Entity set (plural logical name), e.g. "accounts".
220+
records : list[dict]
221+
Each dict must include the real primary key attribute for the entity (e.g. ``accountid``) and one or more
222+
fields to update. If ``@odata.type`` is omitted in any payload, the logical name is resolved once and
223+
stamped into those payloads as ``Microsoft.Dynamics.CRM.<logical>`` (same behaviour as bulk create).
224+
225+
Behaviour
226+
---------
227+
- POST ``/{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple`` with body ``{"Targets": [...]}``.
228+
- Expects Dataverse transactional semantics: if any individual update fails the entire request is rolled back
229+
and an error HTTP status is returned (no partial success handling in V1).
230+
- Response is expected to include an ``Ids`` list (mirrors CreateMultiple); if absent an empty list is
231+
returned.
232+
233+
Returns
234+
-------
235+
None
236+
This method does not return IDs or record bodies. The Dataverse UpdateMultiple action does not
237+
consistently emit identifiers across environments; to keep semantics predictable the SDK returns
238+
nothing on success. Use follow-up queries (e.g. get / get_multiple) if you need refreshed data.
239+
240+
Notes
241+
-----
242+
- Caller must include the correct primary key attribute (e.g. ``accountid``) in every record.
243+
- No representation of updated records is returned; for a single record representation use ``update``.
244+
"""
245+
if not isinstance(records, list) or not records or not all(isinstance(r, dict) for r in records):
246+
raise TypeError("records must be a non-empty list[dict]")
247+
248+
# Determine whether we need logical name resolution (@odata.type missing in any payload)
249+
need_logical = any("@odata.type" not in r for r in records)
250+
logical: Optional[str] = None
251+
if need_logical:
252+
logical = self._logical_from_entity_set(entity_set)
253+
enriched: List[Dict[str, Any]] = []
254+
for r in records:
255+
if "@odata.type" in r or not logical:
256+
enriched.append(r)
257+
else:
258+
nr = r.copy()
259+
nr["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical}"
260+
enriched.append(nr)
261+
262+
payload = {"Targets": enriched}
263+
url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple"
264+
headers = self._headers().copy()
265+
r = self._request("post", url, headers=headers, json=payload)
266+
r.raise_for_status()
267+
# Intentionally ignore response content: no stable contract for IDs across environments.
268+
return None
269+
213270
def delete(self, entity_set: str, key: str) -> None:
214271
"""Delete a record by GUID or alternate key."""
215272
url = f"{self.api}/{entity_set}{self._format_key(key)}"

0 commit comments

Comments
 (0)