Skip to content

Commit 8fcba62

Browse files
Merge pull request #16 from microsoft/user/tpellissier/crud-overloads
Unified interface for create/update/delete
2 parents db7ff4a + 3081914 commit 8fcba62

6 files changed

Lines changed: 317 additions & 187 deletions

File tree

README.md

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Foundry–style apps.
44

55
- Read (SQL) — Execute constrained read-only SQL via the Dataverse Web API `?sql=` parameter. Returns `list[dict]`.
6-
- OData CRUD — Thin wrappers over Dataverse Web API (create/get/update/delete).
7-
- 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`).
6+
- OData CRUD — Unified methods `create(entity, record|records)`, `update(entity, id|ids, patch|patches)`, `delete(entity, id|ids)` plus `get` / `get_multiple`.
7+
- Bulk create — Pass a list of records to `create(...)` to invoke the bound `CreateMultiple` action; returns `list[str]` of GUIDs. If any payload omits `@odata.type` the SDK resolves and stamps it (cached).
8+
- Bulk update — Provide a list of IDs with a single patch (broadcast) or a list of per‑record patches to `update(...)`; internally uses the bound `UpdateMultiple` action; returns nothing. Each record must include the primary key attribute when sent to UpdateMultiple.
99
- Retrieve multiple (paging) — Generator-based `get_multiple(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`).
1010
- Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes).
1111
- Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks.
@@ -17,7 +17,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
1717
- SQL-over-API: Constrained SQL (single SELECT with limited WHERE/TOP/ORDER BY) via native Web API `?sql=` parameter.
1818
- Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them.
1919
- 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.
20+
- Bulk update via `UpdateMultiple` (invoked internally) by calling unified `update(entity_set, ids, patch|patches)`; returns nothing.
2121
- 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`).
2222
- Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query.
2323

@@ -26,6 +26,36 @@ Auth:
2626
- You can pass any `azure.core.credentials.TokenCredential` you prefer; examples use `InteractiveBrowserCredential` for local runs.
2727
- Token scope used by the SDK: `https://<yourorg>.crm.dynamics.com/.default` (derived from `base_url`).
2828

29+
## API Reference (Summary)
30+
31+
| Method | Signature (simplified) | Returns | Notes |
32+
|--------|------------------------|---------|-------|
33+
| `create` | `create(entity_set, record_dict)` | `list[str]` (len 1) | Single create; GUID from `OData-EntityId`. |
34+
| `create` | `create(entity_set, list[record_dict])` | `list[str]` | Uses `CreateMultiple`; stamps `@odata.type` if missing. |
35+
| `get` | `get(entity_set, id)` | `dict` | One record; supply GUID (with/without parentheses). |
36+
| `get_multiple` | `get_multiple(entity_set, ..., page_size=None)` | `Iterable[list[dict]]` | Pages yielded (non-empty only). |
37+
| `update` | `update(entity_set, id, patch)` | `None` | Single update; no representation returned. |
38+
| `update` | `update(entity_set, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs. Calls UpdateMultiple web API internally. |
39+
| `update` | `update(entity_set, list[id], list[patch])` | `None` | 1:1 patches; lengths must match. Calls UpdateMultiple web API internally. |
40+
| `delete` | `delete(entity_set, id)` | `None` | Delete one record. |
41+
| `delete` | `delete(entity_set, list[id])` | `None` | Delete many (sequential). |
42+
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
43+
| `create_table` | `create_table(name, schema)` | `dict` | Creates custom table + columns. |
44+
| `get_table_info` | `get_table_info(name)` | `dict | None` | Basic table metadata. |
45+
| `list_tables` | `list_tables()` | `list[dict]` | Lists non-private tables. |
46+
| `delete_table` | `delete_table(name)` | `None` | Drops custom table. |
47+
| `PandasODataClient.create_df` | `create_df(entity_set, series)` | `str` | Returns GUID (wrapper). |
48+
| `PandasODataClient.update` | `update(entity_set, id, series)` | `None` | Ignores empty Series. |
49+
| `PandasODataClient.get_ids` | `get_ids(entity_set, ids, select=None)` | `DataFrame` | One row per ID (errors inline). |
50+
| `PandasODataClient.query_sql_df` | `query_sql_df(sql)` | `DataFrame` | DataFrame for SQL results. |
51+
52+
Guidelines:
53+
- `create` always returns a list of GUIDs (1 for single, N for bulk).
54+
- `update`/`delete` always return `None` (single and multi forms).
55+
- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list).
56+
- Paging and SQL operations never mutate inputs.
57+
- Metadata lookups for logical name stamping cached per entity set (in-memory).
58+
2959
## Install
3060

3161
Create and activate a Python 3.13+ environment, then install dependencies:
@@ -69,7 +99,8 @@ The quickstart demonstrates:
6999
- Creating a simple custom table (metadata APIs)
70100
- Creating, reading, updating, and deleting records (OData)
71101
- Bulk create (CreateMultiple) to insert many records in one call
72-
- Retrieve multiple with paging (contrasting `$top` vs `page_size`)
102+
- Bulk update via unified `update` (multi-ID broadcast & per‑record patches)
103+
- Retrieve multiple with paging (`$top` vs `page_size`)
73104
- Executing a read-only SQL query (Web API `?sql=`)
74105

75106
## Examples
@@ -92,22 +123,28 @@ from dataverse_sdk import DataverseClient
92123
base_url = "https://yourorg.crm.dynamics.com"
93124
client = DataverseClient(base_url=base_url, credential=DefaultAzureCredential())
94125

95-
# Create (returns created record)
96-
created = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"})
97-
account_id = created["accountid"]
126+
# Create (returns list[str] of new GUIDs)
127+
account_id = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"})[0]
98128

99129
# Read
100130
account = client.get("accounts", account_id)
101131

102-
# Update (returns updated record)
103-
updated = client.update("accounts", account_id, {"telephone1": "555-0199"})
132+
# Update (returns None)
133+
client.update("accounts", account_id, {"telephone1": "555-0199"})
104134

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"},
135+
# Bulk update (broadcast) – apply same patch to several IDs
136+
ids = client.create("accounts", [
137+
{"name": "Contoso"},
138+
{"name": "Fabrikam"},
109139
])
110-
print({"bulk_update": "ok"})
140+
client.update("accounts", ids, {"telephone1": "555-0200"}) # broadcast patch
141+
142+
# Bulk update (1:1) – list of patches matches list of IDs
143+
client.update("accounts", ids, [
144+
{"telephone1": "555-1200"},
145+
{"telephone1": "555-1300"},
146+
])
147+
print({"multi_update": "ok"})
111148

112149
# Delete
113150
client.delete("accounts", account_id)
@@ -119,7 +156,7 @@ for r in rows:
119156

120157
## Bulk create (CreateMultiple)
121158

122-
Pass a list of payloads to `create(entity_set, payloads)` to invoke the collection-bound `Microsoft.Dynamics.CRM.CreateMultiple` action. The method returns a `list[str]` of created record IDs.
159+
Pass a list of payloads to `create(entity_set, payloads)` to invoke the collection-bound `Microsoft.Dynamics.CRM.CreateMultiple` action. The method returns `list[str]` of created record IDs.
123160

124161
```python
125162
# Bulk create accounts (returns list of GUIDs)
@@ -133,34 +170,31 @@ assert isinstance(ids, list) and all(isinstance(x, str) for x in ids)
133170
print({"created_ids": ids})
134171
```
135172

136-
## Bulk update (UpdateMultiple)
173+
## Bulk update (UpdateMultiple under the hood)
137174

138-
Use `update_multiple(entity_set, records)` for a transactional batch update. The method returns `None`.
175+
Use the unified `update` method for both single and bulk scenarios:
139176

140177
```python
141-
ids = client.create("accounts", [
142-
{"name": "Fourth Coffee"},
143-
{"name": "Tailspin"},
144-
])
178+
# Broadcast
179+
client.update("accounts", ids, {"telephone1": "555-0200"})
145180

146-
client.update_multiple("accounts", [
147-
{"accountid": ids[0], "telephone1": "555-1111"},
148-
{"accountid": ids[1], "telephone1": "555-2222"},
181+
# 1:1 patches (length must match)
182+
client.update("accounts", ids, [
183+
{"telephone1": "555-1200"},
184+
{"telephone1": "555-1300"},
149185
])
150-
print({"bulk_update": "ok"})
151186
```
152187

153188
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.
189+
- Returns `None` (same as single update) to keep semantics consistent.
190+
- Broadcast vs per-record determined by whether `changes` is a dict or list.
191+
- Primary key attribute is injected automatically when constructing UpdateMultiple targets.
192+
- If any payload omits `@odata.type`, it's stamped automatically (cached logical name lookup).
158193

159-
Notes:
160-
- The bulk create response typically includes IDs only; the SDK returns the list of GUID strings.
161-
- Single-record `create` still returns the full entity representation.
162-
- `@odata.type` handling: If any payload in the list omits `@odata.type`, the SDK performs a one-time metadata query (`EntityDefinitions?$filter=EntitySetName eq '<entity_set>'`) to resolve the logical name, caches it, and stamps each missing item with `Microsoft.Dynamics.CRM.<logical>`. If **all** payloads already include `@odata.type`, no metadata call is made.
163-
- The metadata lookup is per entity set and reused across subsequent multi-create calls in the same client instance (in-memory cache only).
194+
Bulk create notes:
195+
- Response includes only IDs; the SDK returns those GUID strings.
196+
- Single-record `create` returns a one-element list of GUIDs.
197+
- Metadata lookup for `@odata.type` is performed once per entity set (cached in-memory).
164198

165199

166200
## Retrieve multiple with paging
@@ -267,7 +301,8 @@ client.delete_table("SampleItem") # delete the table
267301
```
268302

269303
Notes:
270-
- `create/update` return the full record using `Prefer: return=representation`.
304+
- `create` always returns a list of GUIDs (length 1 for single input).
305+
- `update` and `delete` return `None` for both single and multi.
271306
- Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs.
272307
- Use `get_multiple` for paging through result sets; prefer `select` to limit columns.
273308
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.
@@ -285,7 +320,7 @@ VS Code Tasks
285320
- No general-purpose OData batching, upsert, or association operations yet.
286321
- `DeleteMultiple` not yet exposed.
287322
- Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency.
288-
- Entity naming conventions in Dataverse: for multi-create the SDK resolves logical names from entity set metadata.
323+
- Entity naming conventions in Dataverse: for bulk create the SDK resolves logical names from entity set metadata.
289324

290325
## Contributing
291326

examples/quickstart.py

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -176,33 +176,25 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
176176
)
177177

178178
record_ids: list[str] = []
179-
created_recs: list[dict] = []
180179

181180
try:
182-
# Single create (always returns full representation)
181+
# Single create returns list[str] (length 1)
183182
log_call(f"client.create('{entity_set}', single_payload)")
184-
# Retry in case the custom table isn't fully provisioned immediately (404)
185-
rec1 = backoff_retry(lambda: client.create(entity_set, single_payload))
186-
created_recs.append(rec1)
187-
rid1 = rec1.get(id_key)
188-
if rid1:
189-
record_ids.append(rid1)
190-
191-
# Multi create (list) now returns list[str] of IDs
183+
single_ids = backoff_retry(lambda: client.create(entity_set, single_payload))
184+
if not (isinstance(single_ids, list) and len(single_ids) == 1):
185+
raise RuntimeError("Unexpected single create return shape (expected one-element list)")
186+
record_ids.extend(single_ids)
187+
188+
# Multi create returns list[str]
192189
log_call(f"client.create('{entity_set}', multi_payloads)")
193190
multi_ids = backoff_retry(lambda: client.create(entity_set, multi_payloads))
194191
if isinstance(multi_ids, list):
195-
for mid in multi_ids:
196-
if isinstance(mid, str):
197-
record_ids.append(mid)
192+
record_ids.extend([mid for mid in multi_ids if isinstance(mid, str)])
198193
else:
199194
print({"multi_unexpected_type": type(multi_ids).__name__, "value_preview": str(multi_ids)[:300]})
200195

201196
print({"entity": logical, "created_ids": record_ids})
202-
summaries = []
203-
for rec in created_recs:
204-
summaries.append({"id": rec.get(id_key), **summary_from_record(rec)})
205-
print_line_summaries("Created record summaries (single only; multi-create returns IDs only):", summaries)
197+
print_line_summaries("Created record summaries (IDs only; representation not fetched):", [{"id": rid} for rid in record_ids[:1]])
206198
except Exception as e:
207199
# Surface detailed info for debugging (especially multi-create failures)
208200
print(f"Create failed: {e}")
@@ -273,16 +265,16 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
273265

274266
# Update only the chosen record and summarize
275267
log_call(f"client.update('{entity_set}', '{target_id}', update_data)")
276-
new_rec = backoff_retry(lambda: client.update(entity_set, target_id, update_data))
277-
# Verify string/int/bool fields
268+
# Perform update (returns None); follow-up read to verify
269+
backoff_retry(lambda: client.update(entity_set, target_id, update_data))
270+
verify_rec = backoff_retry(lambda: client.get(entity_set, target_id))
278271
for k, v in expected_checks.items():
279-
assert new_rec.get(k) == v, f"Field {k} expected {v}, got {new_rec.get(k)}"
280-
# Verify decimal with tolerance
281-
got = new_rec.get(amount_key)
272+
assert verify_rec.get(k) == v, f"Field {k} expected {v}, got {verify_rec.get(k)}"
273+
got = verify_rec.get(amount_key)
282274
got_f = float(got) if got is not None else None
283275
assert got_f is not None and abs(got_f - 543.21) < 1e-6, f"Field {amount_key} expected 543.21, got {got}"
284276
print({"entity": logical, "updated": True})
285-
print_line_summaries("Updated record summary:", [{"id": target_id, **summary_from_record(new_rec)}])
277+
print_line_summaries("Updated record summary:", [{"id": target_id, **summary_from_record(verify_rec)}])
286278
except Exception as e:
287279
print(f"Update/verify failed: {e}")
288280
sys.exit(1)
@@ -300,9 +292,9 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
300292
id_key: rid,
301293
count_key: 100 + idx, # new count values
302294
})
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))
295+
log_call(f"client.update('{entity_set}', <{len(bulk_updates)} ids>, <patches>)")
296+
# Unified update handles multiple via list of patches (returns None)
297+
backoff_retry(lambda: client.update(entity_set, subset, bulk_updates))
306298
print({"bulk_update_requested": len(bulk_updates), "bulk_update_completed": True})
307299
# Verify the updated count values by refetching the subset
308300
verification = []

0 commit comments

Comments
 (0)