Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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). |
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 17 additions & 27 deletions src/dataverse_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]]]:
Comment thread
zhaodongwang-msft marked this conversation as resolved.
"""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,
Expand Down
65 changes: 34 additions & 31 deletions src/dataverse_sdk/odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,44 +118,26 @@ 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.
# --- 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.

Parameters
----------
-------
entity_set : str
Resolved entity set (plural) name.
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.<logical_name>``.
- If all payloads already include ``@odata.type`` no modification occurs.
Singular logical entity name.
record : dict[str, Any]
Attribute payload mapped by logical column names.

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:
"""Create a single record and return its GUID.
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.
"""
Expand All @@ -179,6 +161,27 @@ def _create_single(self, entity_set: str, logical_name: str, record: Dict[str, A
)

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.<logical_name>``.
- 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)
Expand Down
12 changes: 6 additions & 6 deletions tests/test_create_single_guid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})