Skip to content

Commit 3d28963

Browse files
Merge branch 'main' into users/zhaodongwang/uploadFeature
2 parents dee3020 + 8fcba62 commit 3d28963

6 files changed

Lines changed: 318 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
- Upload files — Call `upload_file(entity_set, ...)` and a upload method will be auto picked (user can also overwrite the upload mode). See https://learn.microsoft.com/en-us/power-apps/developer/data-platform/file-column-data?tabs=sdk#upload-files
1111
- Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes).
@@ -18,7 +18,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
1818
- SQL-over-API: Constrained SQL (single SELECT with limited WHERE/TOP/ORDER BY) via native Web API `?sql=` parameter.
1919
- Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them.
2020
- Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(entity_set, payloads)`; returns list of created IDs.
21-
- Bulk update via `UpdateMultiple` by calling `update_multiple(entity_set, records)` with primary key attribute present in each record; returns nothing.
21+
- Bulk update via `UpdateMultiple` (invoked internally) by calling unified `update(entity_set, ids, patch|patches)`; returns nothing.
2222
- 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`).
2323
- Upload files, using either a single request (supports file size up to 128 MB) or chunk upload under the hood
2424
- Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query.
@@ -28,6 +28,36 @@ Auth:
2828
- You can pass any `azure.core.credentials.TokenCredential` you prefer; examples use `InteractiveBrowserCredential` for local runs.
2929
- Token scope used by the SDK: `https://<yourorg>.crm.dynamics.com/.default` (derived from `base_url`).
3030

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

3363
Create and activate a Python 3.13+ environment, then install dependencies:
@@ -71,7 +101,8 @@ The quickstart demonstrates:
71101
- Creating a simple custom table (metadata APIs)
72102
- Creating, reading, updating, and deleting records (OData)
73103
- Bulk create (CreateMultiple) to insert many records in one call
74-
- Retrieve multiple with paging (contrasting `$top` vs `page_size`)
104+
- Bulk update via unified `update` (multi-ID broadcast & per‑record patches)
105+
- Retrieve multiple with paging (`$top` vs `page_size`)
75106
- Executing a read-only SQL query (Web API `?sql=`)
76107

77108
For upload files functionalities, run quickstart_file_upload.py instead
@@ -96,22 +127,28 @@ from dataverse_sdk import DataverseClient
96127
base_url = "https://yourorg.crm.dynamics.com"
97128
client = DataverseClient(base_url=base_url, credential=DefaultAzureCredential())
98129

99-
# Create (returns created record)
100-
created = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"})
101-
account_id = created["accountid"]
130+
# Create (returns list[str] of new GUIDs)
131+
account_id = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"})[0]
102132

103133
# Read
104134
account = client.get("accounts", account_id)
105135

106-
# Update (returns updated record)
107-
updated = client.update("accounts", account_id, {"telephone1": "555-0199"})
136+
# Update (returns None)
137+
client.update("accounts", account_id, {"telephone1": "555-0199"})
108138

109-
# Bulk update (collection-bound UpdateMultiple)
110-
# Each record must include the primary key attribute (accountid). The call returns None.
111-
client.update_multiple("accounts", [
112-
{"accountid": account_id, "telephone1": "555-0200"},
139+
# Bulk update (broadcast) – apply same patch to several IDs
140+
ids = client.create("accounts", [
141+
{"name": "Contoso"},
142+
{"name": "Fabrikam"},
113143
])
114-
print({"bulk_update": "ok"})
144+
client.update("accounts", ids, {"telephone1": "555-0200"}) # broadcast patch
145+
146+
# Bulk update (1:1) – list of patches matches list of IDs
147+
client.update("accounts", ids, [
148+
{"telephone1": "555-1200"},
149+
{"telephone1": "555-1300"},
150+
])
151+
print({"multi_update": "ok"})
115152

116153
# Delete
117154
client.delete("accounts", account_id)
@@ -123,7 +160,7 @@ for r in rows:
123160

124161
## Bulk create (CreateMultiple)
125162

126-
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.
163+
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.
127164

128165
```python
129166
# Bulk create accounts (returns list of GUIDs)
@@ -137,34 +174,31 @@ assert isinstance(ids, list) and all(isinstance(x, str) for x in ids)
137174
print({"created_ids": ids})
138175
```
139176

140-
## Bulk update (UpdateMultiple)
177+
## Bulk update (UpdateMultiple under the hood)
141178

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

144181
```python
145-
ids = client.create("accounts", [
146-
{"name": "Fourth Coffee"},
147-
{"name": "Tailspin"},
148-
])
182+
# Broadcast
183+
client.update("accounts", ids, {"telephone1": "555-0200"})
149184

150-
client.update_multiple("accounts", [
151-
{"accountid": ids[0], "telephone1": "555-1111"},
152-
{"accountid": ids[1], "telephone1": "555-2222"},
185+
# 1:1 patches (length must match)
186+
client.update("accounts", ids, [
187+
{"telephone1": "555-1200"},
188+
{"telephone1": "555-1300"},
153189
])
154-
print({"bulk_update": "ok"})
155190
```
156191

157192
Notes:
158-
- Each record must include the primary key attribute (e.g. `accountid`). No `id` alias yet.
159-
- If any payload omits `@odata.type`, the logical name is resolved once and stamped (same as bulk create).
160-
- Entire request fails (HTTP error) if any individual update fails; no partial success list is returned.
161-
- If you need refreshed records post-update, issue individual `get` calls or a `get_multiple` query.
193+
- Returns `None` (same as single update) to keep semantics consistent.
194+
- Broadcast vs per-record determined by whether `changes` is a dict or list.
195+
- Primary key attribute is injected automatically when constructing UpdateMultiple targets.
196+
- If any payload omits `@odata.type`, it's stamped automatically (cached logical name lookup).
162197

163-
Notes:
164-
- The bulk create response typically includes IDs only; the SDK returns the list of GUID strings.
165-
- Single-record `create` still returns the full entity representation.
166-
- `@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.
167-
- The metadata lookup is per entity set and reused across subsequent multi-create calls in the same client instance (in-memory cache only).
198+
Bulk create notes:
199+
- Response includes only IDs; the SDK returns those GUID strings.
200+
- Single-record `create` returns a one-element list of GUIDs.
201+
- Metadata lookup for `@odata.type` is performed once per entity set (cached in-memory).
168202

169203
## File upload
170204

@@ -284,7 +318,8 @@ client.delete_table("SampleItem") # delete the table
284318
```
285319

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

307342
## Contributing
308343

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)