From 46fa1b722107aa2fc060295e6c235e671afb0341 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Wed, 22 Oct 2025 09:41:55 -0700 Subject: [PATCH 1/3] unify get and refactor create internal implementation --- README.md | 16 ++++++------ examples/quickstart.py | 2 +- src/dataverse_sdk/client.py | 44 ++++++++++++-------------------- src/dataverse_sdk/odata.py | 38 ++------------------------- tests/test_create_single_guid.py | 12 ++++----- 5 files changed, 34 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 3eaa7912..0de1cc57 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ A Python package allowing developers to connect to Dataverse environments for DDL / DML operations. - Read (SQL) — Execute constrained read-only SQL via the Dataverse Web API `?sql=` parameter. Returns `list[dict]`. -- OData CRUD — Unified methods `create(logical_name, record|records)`, `update(logical_name, id|ids, patch|patches)`, `delete(logical_name, id|ids)` plus `get` / `get_multiple`. +- 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. - 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). - 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. -- Retrieve multiple (paging) — Generator-based `get_multiple(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`). +- Retrieve multiple (paging) — Generator-based `get(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`). - Upload files — Call `upload_file(logical_name, ...)` and an upload method will be auto picked (you can override the mode). See https://learn.microsoft.com/en-us/power-apps/developer/data-platform/file-column-data?tabs=sdk#upload-files - Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes). - Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks. @@ -19,7 +19,7 @@ A Python package allowing developers to connect to Dataverse environments for DD - Table metadata ops: create simple custom tables (supports string/int/decimal/float/datetime/bool/optionset) and delete them. - Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(logical_name, payloads)`; returns list of created IDs. - Bulk update via `UpdateMultiple` (invoked internally) by calling unified `update(logical_name, ids, patch|patches)`; returns nothing. -- 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`). +- Retrieve multiple with server-driven paging: `get(...)` yields lists (pages) following `@odata.nextLink`. Control total via `$top` and per-page via `page_size` (Prefer: `odata.maxpagesize`). - Upload files, using either a single request (supports file size up to 128 MB) or chunk upload under the hood - Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query. @@ -35,7 +35,7 @@ Auth: | `create` | `create(logical_name, record_dict)` | `list[str]` (len 1) | Single create; GUID from `OData-EntityId`. | | `create` | `create(logical_name, list[record_dict])` | `list[str]` | Uses `CreateMultiple`; stamps `@odata.type` if missing. | | `get` | `get(logical_name, id)` | `dict` | One record; supply GUID (with/without parentheses). | -| `get_multiple` | `get_multiple(logical_name, ..., page_size=None)` | `Iterable[list[dict]]` | Pages yielded (non-empty only). | +| `get` | `get(logical_name, ..., page_size=None)` | `Iterable[list[dict]]` | Multiple records; Pages yielded (non-empty only). | | `update` | `update(logical_name, id, patch)` | `None` | Single update; no representation returned. | | `update` | `update(logical_name, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs (UpdateMultiple). | | `update` | `update(logical_name, list[id], list[patch])` | `None` | 1:1 patches; lengths must match (UpdateMultiple). | @@ -216,10 +216,10 @@ Notes: ## Retrieve multiple with paging -Use `get_multiple(logical_name, ...)` to stream results page-by-page. You can cap total results with `$top` and hint the per-page size with `page_size` (sets Prefer: `odata.maxpagesize`). +Use `get(logical_name, ...)` to stream results page-by-page. You can cap total results with `$top` and hint the per-page size with `page_size` (sets Prefer: `odata.maxpagesize`). ```python -pages = client.get_multiple( +pages = client.get( "account", select=["accountid", "name", "createdon"], orderby=["name asc"], @@ -255,7 +255,7 @@ Return value & semantics Example (all parameters + expected response) ```python -pages = client.get_multiple( +pages = client.get( "account", select=["accountid", "name", "createdon", "primarycontactid"], filter="contains(name,'Acme') and statecode eq 0", @@ -338,7 +338,7 @@ Notes: - `create` always returns a list of GUIDs (length 1 for single input). - `update` and `delete` return `None` for both single and multi. - Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs. -- Use `get_multiple` for paging through result sets; prefer `select` to limit columns. +- `get` supports single record retrieval with record id or paging through result sets (prefer `select` to limit columns). - For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required. * SQL queries are executed directly against entity set endpoints using the `?sql=` parameter. Supported subset only (single SELECT, optional WHERE/TOP/ORDER BY, alias). Unsupported constructs will be rejected by the service. diff --git a/examples/quickstart.py b/examples/quickstart.py index 3cac3629..891b62a9 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -441,7 +441,7 @@ def run_paging_demo(label: str, *, top: Optional[int], page_size: Optional[int]) page_index = 0 _select = [id_key, code_key, amount_key, when_key, status_key] _orderby = [f"{code_key} asc"] - for page in client.get_multiple( + for page in client.get( logical, select=_select, filter=None, diff --git a/src/dataverse_sdk/client.py b/src/dataverse_sdk/client.py index 186f5030..9bcb472c 100644 --- a/src/dataverse_sdk/client.py +++ b/src/dataverse_sdk/client.py @@ -83,14 +83,15 @@ def create(self, logical_name: str, records: Union[Dict[str, Any], List[Dict[str List of created GUIDs (length 1 for single input). """ od = self._get_odata() + entity_set = od._entity_set_from_logical(logical_name) if isinstance(records, dict): - rid = od._create(logical_name, records) + rid = od._create(entity_set, logical_name, records) # _create returns str on single input if not isinstance(rid, str): raise TypeError("_create (single) did not return GUID string") return [rid] if isinstance(records, list): - ids = od._create(logical_name, records) + ids = od._create_multiple(entity_set, logical_name, records) if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids): raise TypeError("_create (multi) did not return list[str]") return ids @@ -131,39 +132,28 @@ def delete(self, logical_name: str, ids: Union[str, List[str]]) -> None: od._delete_multiple(logical_name, ids) return None - def get(self, logical_name: str, record_id: str) -> dict: - """Fetch a record by ID. - - Parameters - ---------- - logical_name : str - Logical (singular) entity name. - record_id : str - The record GUID (with or without parentheses). - - Returns - ------- - dict - The record JSON payload. - """ - return self._get_odata()._get(logical_name, record_id) - - def get_multiple( + def get( self, logical_name: str, + record_id: Optional[str] = None, select: Optional[List[str]] = None, filter: Optional[str] = None, orderby: Optional[List[str]] = None, top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Iterable[List[Dict[str, Any]]]: - """Fetch multiple records page-by-page as a generator. - - Yields a list of records per page, following @odata.nextLink until exhausted. - Parameters mirror standard OData query options. - """ - return self._get_odata()._get_multiple( + ) -> Union[Dict[str, Any], Iterable[List[Dict[str, Any]]]]: + """Fetch single record by ID or multiple records as a generator.""" + od = self._get_odata() + if record_id is not None: + if not isinstance(record_id, str): + raise TypeError("record_id must be str") + return od._get( + logical_name, + record_id, + select=select, + ) + return od._get_multiple( logical_name, select=select, filter=filter, diff --git a/src/dataverse_sdk/odata.py b/src/dataverse_sdk/odata.py index 1129a6bc..d7416395 100644 --- a/src/dataverse_sdk/odata.py +++ b/src/dataverse_sdk/odata.py @@ -118,42 +118,8 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 2 is_transient=is_transient, ) - # ----------------------------- CRUD --------------------------------- - def _create(self, logical_name: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[str, List[str]]: - """Create one or many records by logical (singular) name. - - Parameters - ---------- - logical_name : str - Logical (singular) entity name, e.g. "account". - data : dict | list[dict] - Single entity payload or list of payloads for batch create. - - Behaviour - --------- - - Resolves entity set once per call via metadata (cached) then issues requests. - - Single (dict): POST /{entity_set}. Returns GUID string (no representation fetched). - - Multiple (list[dict]): POST /{entity_set}/Microsoft.Dynamics.CRM.CreateMultiple. Returns list[str] of created GUIDs. - - Multi-create logical name resolution - ------------------------------------ - - If any payload omits ``@odata.type`` the client stamps ``Microsoft.Dynamics.CRM.``. - - If all payloads already include ``@odata.type`` no modification occurs. - - Returns - ------- - str | list[str] - Created record GUID (single) or list of created IDs (multi). - """ - entity_set = self._entity_set_from_logical(logical_name) - if isinstance(data, dict): - return self._create_single(entity_set, logical_name, data) - if isinstance(data, list): - return self._create_multiple(entity_set, logical_name, data) - raise TypeError("data must be dict or list[dict]") - - # --- Internal helpers --- - def _create_single(self, entity_set: str, logical_name: str, record: Dict[str, Any]) -> str: + # --- CRUD Internal functions --- + def _create(self, entity_set: str, logical_name: str, record: Dict[str, Any]) -> str: """Create a single record and return its GUID. Relies on OData-EntityId (canonical) or Location header. No response body parsing is performed. diff --git a/tests/test_create_single_guid.py b/tests/test_create_single_guid.py index f75d320d..cf85768f 100644 --- a/tests/test_create_single_guid.py +++ b/tests/test_create_single_guid.py @@ -32,23 +32,23 @@ def __init__(self, headers): def _convert_labels_to_ints(self, logical_name, record): # pragma: no cover - test shim return record -def test__create_single_uses_odata_entityid(): +def test__create_uses_odata_entityid(): guid = "11111111-2222-3333-4444-555555555555" headers = {"OData-EntityId": f"https://org.example/api/data/v9.2/accounts({guid})"} c = TestableOData(headers) # Current signature requires logical name explicitly - result = c._create_single("accounts", "account", {"name": "x"}) + result = c._create("accounts", "account", {"name": "x"}) assert result == guid -def test__create_single_fallback_location(): +def test__create_fallback_location(): guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" headers = {"Location": f"https://org.example/api/data/v9.2/contacts({guid})"} c = TestableOData(headers) - result = c._create_single("contacts", "contact", {"firstname": "x"}) + result = c._create("contacts", "contact", {"firstname": "x"}) assert result == guid -def test__create_single_missing_headers_raises(): +def test__create_missing_headers_raises(): c = TestableOData({}) import pytest with pytest.raises(RuntimeError): - c._create_single("accounts", "account", {"name": "x"}) + c._create("accounts", "account", {"name": "x"}) From 8f6498f9edd719fdc0f343842ad7690896c28246 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Wed, 22 Oct 2025 09:59:29 -0700 Subject: [PATCH 2/3] description update --- src/dataverse_sdk/odata.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/dataverse_sdk/odata.py b/src/dataverse_sdk/odata.py index d7416395..f7d893c3 100644 --- a/src/dataverse_sdk/odata.py +++ b/src/dataverse_sdk/odata.py @@ -122,6 +122,22 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 2 def _create(self, entity_set: str, logical_name: str, record: Dict[str, Any]) -> str: """Create a single record and return its GUID. + Parameters + ------- + entity_set : str + Resolved entity set (plural) name. + logical_name : str + Singular logical entity name. + record : dict[str, Any] + Attribute payload mapped by logical column names. + + Returns + ------- + str + Created record GUID. + + Notes + ------- Relies on OData-EntityId (canonical) or Location header. No response body parsing is performed. Raises RuntimeError if neither header contains a GUID. """ @@ -145,6 +161,27 @@ def _create(self, entity_set: str, logical_name: str, record: Dict[str, Any]) -> ) def _create_multiple(self, entity_set: str, logical_name: str, records: List[Dict[str, Any]]) -> List[str]: + """Create multiple records using the collection-bound CreateMultiple action. + + Parameters + ---------- + entity_set : str + Resolved entity set (plural) name. + logical_name : str + Singular logical entity name. + records : list[dict[str, Any]] + Payloads mapped by logical attribute names. + + Multi-create logical name resolution + ------------------------------------ + - If any payload omits ``@odata.type`` the client stamps ``Microsoft.Dynamics.CRM.``. + - If all payloads already include ``@odata.type`` no modification occurs. + + Returns + ------- + list[str] + List of created IDs. + """ if not all(isinstance(r, dict) for r in records): raise TypeError("All items for multi-create must be dicts") need_logical = any("@odata.type" not in r for r in records) From de54f6b3ff541609f96677b13be38637978a7114 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Wed, 22 Oct 2025 14:26:35 -0700 Subject: [PATCH 3/3] indent --- src/dataverse_sdk/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataverse_sdk/client.py b/src/dataverse_sdk/client.py index 9bcb472c..0b8db223 100644 --- a/src/dataverse_sdk/client.py +++ b/src/dataverse_sdk/client.py @@ -152,7 +152,7 @@ def get( logical_name, record_id, select=select, - ) + ) return od._get_multiple( logical_name, select=select,