Skip to content

Commit ddca15d

Browse files
author
Max Wang
committed
bulk delete
1 parent fe72421 commit ddca15d

4 files changed

Lines changed: 280 additions & 55 deletions

File tree

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ 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])` | `None` | Delete many (sequential). |
42+
| `delete` | `delete(logical_name, list[id], ..., wait_poll_interval_seconds=2.0)` | `Optional[str]` | Delete many with async BulkDelete. |
4343
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
4444
| `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. |
4545
| `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,8 +54,10 @@ Auth:
5454

5555
Guidelines:
5656
- `create` always returns a list of GUIDs (1 for single, N for bulk).
57-
- `update`/`delete` always return `None` (single and multi forms).
57+
- `update` always returns `None`.
5858
- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list).
59+
- `delete` returns `None` for single-record delete and the BulkDelete async job ID for multi-record delete.
60+
- By default multi-record delete doesn't wait for the async job to complete. User can optionally wait for the job to complete.,klmmm
5961
- Paging and SQL operations never mutate inputs.
6062
- Metadata lookups for logical name stamping cached per entity set (in-memory).
6163

@@ -143,9 +145,12 @@ client.update("account", ids, [
143145
])
144146
print({"multi_update": "ok"})
145147

146-
# Delete
148+
# Delete (single)
147149
client.delete("account", account_id)
148150

151+
# Bulk delete (schedules BulkDelete and returns job id)
152+
job_id = client.delete("account", ids)
153+
149154
# SQL (read-only) via Web API `?sql=`
150155
rows = client.query_sql("SELECT TOP 3 accountid, name FROM account ORDER BY createdon DESC")
151156
for r in rows:
@@ -334,7 +339,7 @@ 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` and `delete` return `None` for both single and multi.
342+
- `update` returns `None`. `delete` returns `None` for single-record delete and the BulkDelete async job ID for multi-record delete.
338343
- Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs.
339344
- `get` supports single record retrieval with record id or paging through result sets (prefer `select` to limit columns).
340345
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.
@@ -350,7 +355,6 @@ VS Code Tasks
350355

351356
## Limitations / Future Work
352357
- No general-purpose OData batching, upsert, or association operations yet.
353-
- `DeleteMultiple` not yet exposed.
354358
- Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency.
355359

356360
## Contributing

examples/quickstart.py

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import requests
1515
import time
1616
from datetime import date, timedelta
17-
from concurrent.futures import ThreadPoolExecutor, as_completed
1817

1918

2019
entered = input("Enter Dataverse org URL (e.g. https://yourorg.crm.dynamics.com): ").strip()
@@ -57,10 +56,12 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
5756
print(f'Request failed: {ex}')
5857
last_exc = ex
5958
if retry_if and retry_if(ex):
59+
print("Retrying operation...")
6060
continue
6161
if isinstance(ex, requests.exceptions.HTTPError):
6262
code = getattr(getattr(ex, 'response', None), 'status_code', None)
6363
if code in retry_http_statuses:
64+
print("Retrying operation...")
6465
continue
6566
break
6667
if last_exc:
@@ -176,20 +177,6 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
176177
f"count={s.get('count')} amount={s.get('amount')} when={s.get('when')}"
177178
)
178179

179-
def _resolve_status_value(kind: str, raw_value, use_french: bool):
180-
"""kind values:
181-
- 'label': English label
182-
- 'fr_label': French label if allowed, else fallback to English equivalent
183-
- 'int': the enum integer value
184-
"""
185-
if kind == "label":
186-
return raw_value
187-
if kind == "fr_label":
188-
if use_french:
189-
return raw_value
190-
return "Active" if raw_value == "Actif" else "Inactive"
191-
return raw_value
192-
193180
def _has_installed_language(base_url: str, credential, lcid: int) -> bool:
194181
try:
195182
token = credential.get_token(f"{base_url}/.default").token
@@ -496,39 +483,64 @@ def run_paging_demo(label: str, *, top: Optional[int], page_size: Optional[int])
496483
print(f"Retrieve multiple demos failed: {e}")
497484
# 5) Delete record
498485
print("Delete (OData):")
499-
# Show deletes to be executed (concurrently via SDK delete)
486+
# Show deletes to be executed (single + bulk)
500487
if 'record_ids' in locals() and record_ids:
501488
print({"delete_count": len(record_ids)})
502-
pause("Execute Delete (concurrent SDK calls)")
489+
pause("Execute Delete (single then bulk)")
503490
try:
504491
if record_ids:
505-
max_workers = min(8, len(record_ids))
506-
log_call(f"concurrent delete {len(record_ids)} items from '{logical}' (workers={max_workers})")
492+
single_target = record_ids[0]
493+
rest_targets = record_ids[1:]
494+
single_error: Optional[str] = None
495+
bulk_job_id: Optional[str] = None
496+
bulk_error: Optional[str] = None
497+
bulk_wait_job_id: Optional[str] = None
498+
bulk_wait_error: Optional[str] = None
499+
async_targets: list[str] = []
500+
wait_targets: list[str] = []
501+
502+
try:
503+
log_call(f"client.delete('{logical}', '{single_target}')")
504+
backoff_retry(lambda: client.delete(logical, single_target))
505+
except Exception as ex:
506+
single_error = str(ex)
507507

508-
successes: list[str] = []
509-
failures: list[dict] = []
508+
if rest_targets:
509+
half = len(rest_targets) // 2
510+
async_targets = rest_targets[:half]
511+
wait_targets = rest_targets[half:]
510512

511-
def _del_one(rid: str) -> tuple[str, bool, str | None]:
512513
try:
513-
log_call(f"client.delete('{logical}', '{rid}')")
514-
backoff_retry(lambda: client.delete(logical, rid))
515-
return (rid, True, None)
514+
log_call(f"client.delete('{logical}', <{len(async_targets)} ids>) [fire-and-forget]")
515+
bulk_job_id = client.delete(logical, async_targets)
516516
except Exception as ex:
517-
return (rid, False, str(ex))
518-
519-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
520-
future_map = {executor.submit(_del_one, rid): rid for rid in record_ids}
521-
for fut in as_completed(future_map):
522-
rid, ok, err = fut.result()
523-
if ok:
524-
successes.append(rid)
525-
else:
526-
failures.append({"id": rid, "error": err})
517+
bulk_error = str(ex)
518+
try:
519+
log_call(f"client.delete('{logical}', <{len(wait_targets)} ids>, wait=True)")
520+
bulk_wait_job_id = client.delete(
521+
logical,
522+
wait_targets,
523+
wait=True,
524+
)
525+
except Exception as ex:
526+
bulk_wait_error = str(ex)
527527

528528
print({
529529
"entity": logical,
530-
"delete_summary": {"requested": len(record_ids), "success": len(successes), "failures": len(failures)},
531-
"failed": failures[:5], # preview up to 5 failures
530+
"delete_single": {
531+
"id": single_target,
532+
"error": single_error,
533+
},
534+
"delete_bulk_fire_and_forget": {
535+
"count": len(async_targets) if rest_targets else 0,
536+
"job_id": bulk_job_id,
537+
"error": bulk_error,
538+
},
539+
"delete_bulk_wait": {
540+
"count": len(wait_targets) if rest_targets else 0,
541+
"job_id": bulk_wait_job_id,
542+
"error": bulk_wait_error,
543+
},
532544
})
533545
else:
534546
raise RuntimeError("No record created; skipping delete.")
@@ -577,8 +589,22 @@ def _metadata_after_create():
577589
if isinstance(raw_type, str):
578590
attr_type_before = raw_type
579591
lowered = raw_type.lower()
580-
log_call(f"client.delete_column('{entity_schema}', '{scratch_column}')")
581-
column_delete = client.delete_columns(entity_schema, scratch_column)
592+
delete_target = attribute_schema or scratch_column
593+
log_call(f"client.delete_column('{entity_schema}', '{delete_target}')")
594+
595+
def _delete_column():
596+
return client.delete_columns(entity_schema, delete_target)
597+
598+
column_delete = backoff_retry(
599+
_delete_column,
600+
delays=(0, 1, 2, 4, 8),
601+
retry_http_statuses=(),
602+
retry_if=lambda exc: (
603+
isinstance(exc, MetadataError)
604+
or "not found" in str(exc).lower()
605+
or "not yet available" in str(exc).lower()
606+
),
607+
)
582608
if not isinstance(column_delete, list) or not column_delete:
583609
raise RuntimeError("delete_column did not return schema list")
584610
deleted_details = column_delete

src/dataverse_sdk/client.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,32 @@ def update(self, logical_name: str, ids: Union[str, List[str]], changes: Union[D
201201
od._update_by_ids(logical_name, ids, changes)
202202
return None
203203

204-
def delete(self, logical_name: str, ids: Union[str, List[str]]) -> None:
204+
def delete(
205+
self,
206+
logical_name: str,
207+
ids: Union[str, List[str]],
208+
wait: bool = False,
209+
wait_timeout_seconds: Optional[int] = 300,
210+
wait_poll_interval_seconds: float = 2.0,
211+
) -> Optional[str]:
205212
"""
206213
Delete one or more records by GUID.
207214
208215
:param logical_name: Logical (singular) entity name, e.g. ``"account"``.
209216
:type logical_name: str
210217
:param ids: Single GUID string or list of GUID strings to delete.
211218
:type ids: str or list[str]
212-
219+
:param wait: When deleting multiple records, wait for the background job to complete. Ignored for single deletes.
220+
:type wait: bool
221+
:param wait_timeout_seconds: Optional timeout applied when ``wait`` is True. ``None`` or
222+
values ``<= 0`` wait indefinitely. Defaults to 300 seconds.
223+
:type wait_timeout_seconds: int or None
224+
:param wait_poll_interval_seconds: Poll interval used while waiting for job completion.
225+
:type wait_poll_interval_seconds: float
213226
:raises TypeError: If ``ids`` is not str or list[str].
227+
228+
:return: BulkDelete job ID when deleting multiple records; otherwise ``None``.
229+
:rtype: str or None
214230
215231
Example:
216232
Delete a single record::
@@ -219,16 +235,21 @@ def delete(self, logical_name: str, ids: Union[str, List[str]]) -> None:
219235
220236
Delete multiple records::
221237
222-
client.delete("account", [id1, id2, id3])
238+
job_id = client.delete("account", [id1, id2, id3])
223239
"""
224240
od = self._get_odata()
225241
if isinstance(ids, str):
226242
od._delete(logical_name, ids)
227243
return None
228244
if not isinstance(ids, list):
229245
raise TypeError("ids must be str or list[str]")
230-
od._delete_multiple(logical_name, ids)
231-
return None
246+
return od._delete_multiple(
247+
logical_name,
248+
ids,
249+
wait=wait,
250+
timeout_seconds=wait_timeout_seconds,
251+
poll_interval_seconds=wait_poll_interval_seconds,
252+
)
232253

233254
def get(
234255
self,

0 commit comments

Comments
 (0)