Skip to content

Commit b07e5ae

Browse files
author
Max Wang
committed
stash
1 parent de6539c commit b07e5ae

4 files changed

Lines changed: 254 additions & 26 deletions

File tree

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
A Python package allowing developers to connect to Dataverse environments for DDL / DML operations.
44

55
- Read (SQL) — Execute constrained read-only SQL via the Dataverse Web API `?sql=` parameter. Returns `list[dict]`.
6-
- OData CRUD — Unified methods `create(logical_name, record|records)`, `update(logical_name, id|ids, patch|patches)`, `delete(logical_name, id|ids)` plus `get` with record id or filters.
6+
- OData CRUD — Unified methods `create(logical_name, record|records)`, `update(logical_name, id|ids, patch|patches)`, `delete(logical_name, id|ids)` plus `get` with record id or filters and `delete_async` for better multi-record delete performance.
77
- 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).
88
- 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(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`).
@@ -39,7 +39,9 @@ Auth:
3939
| `update` | `update(logical_name, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs (UpdateMultiple). |
4040
| `update` | `update(logical_name, list[id], list[patch])` | `None` | 1:1 patches; lengths must match (UpdateMultiple). |
4141
| `delete` | `delete(logical_name, id)` | `None` | Delete one record. |
42-
| `delete` | `delete(logical_name, list[id], use_bulk_delete=True)` | `Optional[str]` | Delete many with async BulkDelete or sequential single-record delete. |
42+
| `delete` | `delete(logical_name, list[id])` | `None` | Sequential deletes (loops over single-record delete). |
43+
| `delete_async` | `delete_async(logical_name, id)` | `str` | Async single-record delete. |
44+
| `delete_async` | `delete_async(logical_name, list[id])` | `str` | Async multi-record delete. |
4345
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
4446
| `create_table` | `create_table(tablename, schema, solution_unique_name=None)` | `dict` | Creates custom table + columns. Friendly name (e.g. `SampleItem`) becomes schema `new_SampleItem`; explicit schema name (contains `_`) used as-is. Pass `solution_unique_name` to attach the table to a specific solution instead of the default solution. |
4547
| `create_column` | `create_column(tablename, columns)` | `list[str]` | Adds columns using a `{name: type}` mapping (same shape as `create_table` schema). Returns schema names for the created columns. |
@@ -54,10 +56,10 @@ Auth:
5456

5557
Guidelines:
5658
- `create` always returns a list of GUIDs (1 for single, N for bulk).
57-
- `update` always returns `None`.
59+
- `update` and `delete` always returns `None`.
5860
- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list).
59-
- `delete` returns `None` for single-record delete and sequential multi-record delete, and the BulkDelete async job ID for multi-record BulkDelete.
60-
- BulkDelete doesn't wait for the delete job to complete. It returns once the async delete job is scheduled.
61+
- `delete_async` returns the BulkDelete async job ID and doesn't wait for completion.
62+
- It's recommended to use delete_async for multi-record delete for better performance.
6163
- Paging and SQL operations never mutate inputs.
6264
- Metadata lookups for logical name stamping cached per entity set (in-memory).
6365

@@ -143,8 +145,11 @@ print({"multi_update": "ok"})
143145
# Delete (single)
144146
client.delete("account", account_id)
145147

146-
# Bulk delete (schedules BulkDelete and returns job id)
147-
job_id = client.delete("account", ids)
148+
# Delete multiple sequentially
149+
client.delete("account", ids)
150+
151+
# Or queue a async bulk delete job
152+
job_id = client.delete_async("account", ids)
148153

149154
# SQL (read-only) via Web API `?sql=`
150155
rows = client.query_sql("SELECT TOP 3 accountid, name FROM account ORDER BY createdon DESC")
@@ -334,8 +339,8 @@ client.delete_table("SampleItem") # delete table (friendly name or explici
334339

335340
Notes:
336341
- `create` always returns a list of GUIDs (length 1 for single input).
337-
- `update` returns `None`.
338-
- `delete` returns `None` for single-record delete/sequential multi-record delete, and the BulkDelete async job ID for BulkDelete.
342+
- `update` and `delete` returns `None`.
343+
- `delete_async` returns the BulkDelete async job ID.
339344
- Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs.
340345
- `get` supports single record retrieval with record id or paging through result sets (prefer `select` to limit columns).
341346
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.

examples/quickstart.py

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
# Create a credential we can reuse (for DataverseClient)
3434
credential = InteractiveBrowserCredential()
3535
client = DataverseClient(base_url=base_url, credential=credential)
36+
elastic_table_schema = "new_ElasticDeleteDemo"
37+
elastic_table_created_this_run = False
38+
elastic_table_logical_name: Optional[str] = None
39+
elastic_table_metadata_id: Optional[str] = None
3640

3741
# Small helpers: call logging and step pauses
3842
def log_call(call: str) -> None:
@@ -69,6 +73,129 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
6973
break
7074
if last_exc:
7175
raise last_exc
76+
77+
def run_elastic_delete_demo() -> None:
78+
global elastic_table_created_this_run, elastic_table_logical_name, elastic_table_metadata_id
79+
print("Elastic DeleteMultiple demo (elastic table setup):")
80+
odata_client = client._get_odata()
81+
schema_name = elastic_table_schema
82+
publisher_prefix = schema_name.split("_", 1)[0] if "_" in schema_name else schema_name
83+
primary_attr = f"{publisher_prefix}_Name"
84+
count_attr = f"{publisher_prefix}_Count"
85+
flag_attr = f"{publisher_prefix}_Flag"
86+
87+
def _fetch_metadata() -> Optional[dict]:
88+
url = f"{odata_client.api}/EntityDefinitions"
89+
params = {
90+
"$select": "MetadataId,LogicalName,EntitySetName,SchemaName,TableType",
91+
"$filter": f"SchemaName eq '{schema_name}'",
92+
}
93+
r = odata_client._request("get", url, params=params)
94+
try:
95+
body = r.json() if r.text else {}
96+
except ValueError:
97+
return None
98+
items = body.get("value") if isinstance(body, dict) else None
99+
if isinstance(items, list) and items:
100+
md = items[0]
101+
return md if isinstance(md, dict) else None
102+
return None
103+
104+
try:
105+
metadata = _fetch_metadata()
106+
if metadata and str(metadata.get("TableType", "")).lower() != "elastic":
107+
print({
108+
"elastic_table": schema_name,
109+
"skipped": True,
110+
"reason": "Existing table is not elastic; DeleteMultiple demo not run",
111+
"table_type": metadata.get("TableType"),
112+
})
113+
return
114+
if not metadata:
115+
log_call(f"POST EntityDefinitions (TableType=Elastic) for {schema_name}")
116+
attributes = [
117+
odata_client._attribute_payload(primary_attr, "string", is_primary_name=True),
118+
odata_client._attribute_payload(count_attr, "int"),
119+
odata_client._attribute_payload(flag_attr, "bool"),
120+
]
121+
attrs = [a for a in attributes if a]
122+
payload = {
123+
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
124+
"SchemaName": schema_name,
125+
"DisplayName": odata_client._label("Elastic Delete Demo"),
126+
"DisplayCollectionName": odata_client._label("Elastic Delete Demos"),
127+
"Description": odata_client._label("Elastic table for DeleteMultiple quickstart validation"),
128+
"OwnershipType": "UserOwned",
129+
"HasActivities": False,
130+
"HasNotes": True,
131+
"IsEnabledForCharts": False,
132+
"IsVirtualEntityReportingEnabled": False,
133+
"IsActivity": False,
134+
"TableType": "Elastic",
135+
"Attributes": attrs,
136+
}
137+
def _create_elastic():
138+
odata_client._request("post", f"{odata_client.api}/EntityDefinitions", json=payload)
139+
return True
140+
try:
141+
backoff_retry(_create_elastic)
142+
except Exception as create_exc:
143+
print({
144+
"elastic_table": schema_name,
145+
"skipped": True,
146+
"reason": "Elastic table creation failed",
147+
"error": str(create_exc),
148+
})
149+
return
150+
ready = backoff_retry(lambda: odata_client._wait_for_entity_ready(schema_name), retry_http_statuses=())
151+
if not ready or not ready.get("MetadataId"):
152+
raise RuntimeError("Elastic demo table metadata not ready")
153+
metadata = _fetch_metadata()
154+
elastic_table_created_this_run = True
155+
if not metadata:
156+
print({"elastic_table": schema_name, "skipped": True, "reason": "Metadata unavailable"})
157+
return
158+
elastic_table_logical_name = metadata.get("LogicalName")
159+
elastic_table_metadata_id = metadata.get("MetadataId")
160+
odata_client._elastic_table_cache.pop(elastic_table_logical_name, None)
161+
odata_client._logical_to_entityset_cache.pop(elastic_table_logical_name, None)
162+
odata_client._logical_primaryid_cache.pop(elastic_table_logical_name, None)
163+
is_elastic = odata_client._is_elastic_table(elastic_table_logical_name) if elastic_table_logical_name else False
164+
print({
165+
"elastic_table": schema_name,
166+
"logical_name": elastic_table_logical_name,
167+
"table_type_reported": metadata.get("TableType"),
168+
"is_elastic": bool(is_elastic),
169+
})
170+
logical = elastic_table_logical_name
171+
if not logical:
172+
print({"elastic_table": schema_name, "skipped": True, "reason": "Logical name missing"})
173+
return
174+
prefix = logical.split("_", 1)[0] if "_" in logical else logical
175+
name_key = f"{prefix}_name"
176+
count_key = f"{prefix}_count"
177+
flag_key = f"{prefix}_flag"
178+
records = [
179+
{name_key: "Elastic Demo A", count_key: 10, flag_key: True},
180+
{name_key: "Elastic Demo B", count_key: 11, flag_key: False},
181+
{name_key: "Elastic Demo C", count_key: 12, flag_key: True},
182+
]
183+
log_call(f"client.create('{logical}', <{len(records)} elastic records>)")
184+
created_ids = backoff_retry(lambda: client.create(logical, records))
185+
valid_ids = [rid for rid in created_ids if isinstance(rid, str)] if isinstance(created_ids, list) else []
186+
if not valid_ids:
187+
raise RuntimeError("Elastic demo record creation returned no GUIDs")
188+
print({"elastic_create_ids": valid_ids})
189+
log_call(f"client.delete('{logical}', <{len(valid_ids)} elastic ids>)")
190+
backoff_retry(lambda: client.delete(logical, valid_ids))
191+
print({
192+
"elastic_delete_multiple": {
193+
"requested": len(valid_ids),
194+
"succeeded": True,
195+
}
196+
})
197+
except Exception as exc:
198+
print({"elastic_demo_error": str(exc)})
72199

73200
# Enum demonstrating local option set creation with multilingual labels (for French labels to work, enable French language in the environment first)
74201
class Status(IntEnum):
@@ -88,6 +215,10 @@ class Status(IntEnum):
88215
}
89216
}
90217

218+
pause("Run elastic DeleteMultiple demo")
219+
run_elastic_delete_demo()
220+
sys.exit(0)
221+
91222
print("Ensure custom table exists (Metadata):")
92223
table_info = None
93224
created_this_run = False
@@ -512,16 +643,16 @@ def run_paging_demo(label: str, *, top: Optional[int], page_size: Optional[int])
512643

513644
# Fire-and-forget bulk delete for the first portion
514645
try:
515-
log_call(f"client.delete('{logical}', <{len(bulk_targets)} ids>, use_bulk_delete=True)")
516-
bulk_job_id = client.delete(logical, bulk_targets)
646+
log_call(f"client.delete_async('{logical}', <{len(bulk_targets)} ids>)")
647+
bulk_job_id = client.delete_async(logical, bulk_targets)
517648
except Exception as ex:
518649
bulk_error = str(ex)
519650

520651
# Sequential deletes for the remainder
521652
try:
522-
log_call(f"client.delete('{logical}', <{len(sequential_targets)} ids>, use_bulk_delete=False)")
653+
log_call(f"client.delete('{logical}', <{len(sequential_targets)} ids>)")
523654
for rid in sequential_targets:
524-
backoff_retry(lambda rid=rid: client.delete(logical, rid, use_bulk_delete=False))
655+
backoff_retry(lambda rid=rid: client.delete(logical, rid))
525656
except Exception as ex:
526657
sequential_error = str(ex)
527658

@@ -655,5 +786,16 @@ def _ensure_removed():
655786
print({"table_deleted": False, "reason": "not found"})
656787
except Exception as e:
657788
print(f"Delete table failed: {e}")
789+
if elastic_table_created_this_run:
790+
try:
791+
log_call(f"client.delete_table('{elastic_table_schema}')")
792+
client.delete_table(elastic_table_schema)
793+
print({"elastic_table_deleted": True})
794+
except Exception as e:
795+
print({"elastic_table_delete_error": str(e)})
796+
elif elastic_table_logical_name:
797+
print({"elastic_table_deleted": False, "reason": "skipped (table existed before run)"})
658798
else:
659799
print({"table_deleted": False, "reason": "user opted to keep table"})
800+
if elastic_table_created_this_run:
801+
print({"elastic_table_deleted": False, "reason": "user opted to keep table"})

src/dataverse_sdk/client.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from .config import DataverseConfig
1212
from .odata import ODataClient
1313

14-
1514
class DataverseClient:
1615
"""
1716
High-level client for Microsoft Dataverse operations.
@@ -208,24 +207,20 @@ def delete(
208207
self,
209208
logical_name: str,
210209
ids: Union[str, List[str]],
211-
use_bulk_delete: bool = True,
212-
) -> Optional[str]:
210+
) -> None:
213211
"""
214212
Delete one or more records by GUID.
215213
216214
:param logical_name: Logical (singular) entity name, e.g. ``"account"``.
217215
:type logical_name: str
218216
:param ids: Single GUID string or list of GUID strings to delete.
219217
:type ids: str or list[str]
220-
:param use_bulk_delete: When ``True`` (default) and ``ids`` is a list, execute the BulkDelete action and
221-
return its async job identifier. When ``False`` each record is deleted sequentially.
222-
:type use_bulk_delete: bool
223218
224219
:raises TypeError: If ``ids`` is not str or list[str].
225220
:raises HttpError: If the underlying Web API delete request fails.
226-
227-
:return: BulkDelete job ID when deleting multiple records via BulkDelete; otherwise ``None``.
228-
:rtype: str or None
221+
222+
:return: ``None`` once the requested records have been deleted sequentially.
223+
:rtype: None
229224
230225
Example:
231226
Delete a single record::
@@ -234,7 +229,7 @@ def delete(
234229
235230
Delete multiple records::
236231
237-
job_id = client.delete("account", [id1, id2, id3])
232+
client.delete("account", [id1, id2, id3])
238233
"""
239234
od = self._get_odata()
240235
if isinstance(ids, str):
@@ -246,12 +241,50 @@ def delete(
246241
return None
247242
if not all(isinstance(rid, str) for rid in ids):
248243
raise TypeError("ids must contain string GUIDs")
249-
if use_bulk_delete:
250-
return od._delete_multiple(logical_name, ids)
244+
if od._is_elastic_table(logical_name):
245+
od._delete_multiple(logical_name, ids)
246+
return None
251247
for rid in ids:
252248
od._delete(logical_name, rid)
253249
return None
254250

251+
def delete_async(
252+
self,
253+
logical_name: str,
254+
ids: Union[str, List[str]],
255+
) -> str:
256+
"""
257+
Issue an asynchronous BulkDelete job for one or more records.
258+
259+
:param logical_name: Logical (singular) entity name, e.g. ``"account"``.
260+
:type logical_name: str
261+
:param ids: Single GUID string or list of GUID strings to delete.
262+
:type ids: str or list[str]
263+
264+
:raises TypeError: If ``ids`` is not str or list[str].
265+
:raises HttpError: If the BulkDelete request fails.
266+
267+
:return: BulkDelete job identifier, a dummy if ids is empty.
268+
:rtype: str
269+
270+
Example:
271+
Queue a bulk delete::
272+
273+
job_id = client.delete_async("account", [id1, id2, id3])
274+
"""
275+
od = self._get_odata()
276+
if isinstance(ids, str):
277+
return od._delete_async(logical_name, [ids])
278+
elif isinstance(ids, list):
279+
if not ids:
280+
noop_bulkdelete_job_id = "00000000-0000-0000-0000-000000000000"
281+
return noop_bulkdelete_job_id
282+
if not all(isinstance(rid, str) for rid in ids):
283+
raise TypeError("ids must contain string GUIDs")
284+
return od._delete_async(logical_name, ids)
285+
else:
286+
raise TypeError("ids must be str or list[str]")
287+
255288
def get(
256289
self,
257290
logical_name: str,

0 commit comments

Comments
 (0)