diff --git a/README.md b/README.md index e9a87808..70b737ee 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Foundry–style apps. - Read (SQL) — Execute constrained read-only SQL via the Dataverse Web API `?sql=` parameter. Returns `list[dict]`. -- OData CRUD — Thin wrappers over Dataverse Web API (create/get/update/delete). -- 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). -- 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`). +- OData CRUD — Unified methods `create(entity, record|records)`, `update(entity, id|ids, patch|patches)`, `delete(entity, id|ids)` plus `get` / `get_multiple`. +- 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`). - Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes). - Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks. @@ -17,7 +17,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found - SQL-over-API: Constrained SQL (single SELECT with limited WHERE/TOP/ORDER BY) via native Web API `?sql=` parameter. - Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them. - Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(entity_set, payloads)`; returns list of created IDs. -- Bulk update via `UpdateMultiple` by calling `update_multiple(entity_set, records)` with primary key attribute present in each record; returns nothing. +- Bulk update via `UpdateMultiple` (invoked internally) by calling unified `update(entity_set, 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`). - Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query. @@ -26,6 +26,36 @@ Auth: - You can pass any `azure.core.credentials.TokenCredential` you prefer; examples use `InteractiveBrowserCredential` for local runs. - Token scope used by the SDK: `https://.crm.dynamics.com/.default` (derived from `base_url`). +## API Reference (Summary) + +| Method | Signature (simplified) | Returns | Notes | +|--------|------------------------|---------|-------| +| `create` | `create(entity_set, record_dict)` | `list[str]` (len 1) | Single create; GUID from `OData-EntityId`. | +| `create` | `create(entity_set, list[record_dict])` | `list[str]` | Uses `CreateMultiple`; stamps `@odata.type` if missing. | +| `get` | `get(entity_set, id)` | `dict` | One record; supply GUID (with/without parentheses). | +| `get_multiple` | `get_multiple(entity_set, ..., page_size=None)` | `Iterable[list[dict]]` | Pages yielded (non-empty only). | +| `update` | `update(entity_set, id, patch)` | `None` | Single update; no representation returned. | +| `update` | `update(entity_set, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs. Calls UpdateMultiple web API internally. | +| `update` | `update(entity_set, list[id], list[patch])` | `None` | 1:1 patches; lengths must match. Calls UpdateMultiple web API internally. | +| `delete` | `delete(entity_set, id)` | `None` | Delete one record. | +| `delete` | `delete(entity_set, list[id])` | `None` | Delete many (sequential). | +| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. | +| `create_table` | `create_table(name, schema)` | `dict` | Creates custom table + columns. | +| `get_table_info` | `get_table_info(name)` | `dict | None` | Basic table metadata. | +| `list_tables` | `list_tables()` | `list[dict]` | Lists non-private tables. | +| `delete_table` | `delete_table(name)` | `None` | Drops custom table. | +| `PandasODataClient.create_df` | `create_df(entity_set, series)` | `str` | Returns GUID (wrapper). | +| `PandasODataClient.update` | `update(entity_set, id, series)` | `None` | Ignores empty Series. | +| `PandasODataClient.get_ids` | `get_ids(entity_set, ids, select=None)` | `DataFrame` | One row per ID (errors inline). | +| `PandasODataClient.query_sql_df` | `query_sql_df(sql)` | `DataFrame` | DataFrame for SQL results. | + +Guidelines: +- `create` always returns a list of GUIDs (1 for single, N for bulk). +- `update`/`delete` always return `None` (single and multi forms). +- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list). +- Paging and SQL operations never mutate inputs. +- Metadata lookups for logical name stamping cached per entity set (in-memory). + ## Install Create and activate a Python 3.13+ environment, then install dependencies: @@ -69,7 +99,8 @@ The quickstart demonstrates: - Creating a simple custom table (metadata APIs) - Creating, reading, updating, and deleting records (OData) - Bulk create (CreateMultiple) to insert many records in one call -- Retrieve multiple with paging (contrasting `$top` vs `page_size`) +- Bulk update via unified `update` (multi-ID broadcast & per‑record patches) +- Retrieve multiple with paging (`$top` vs `page_size`) - Executing a read-only SQL query (Web API `?sql=`) ## Examples @@ -92,22 +123,28 @@ from dataverse_sdk import DataverseClient base_url = "https://yourorg.crm.dynamics.com" client = DataverseClient(base_url=base_url, credential=DefaultAzureCredential()) -# Create (returns created record) -created = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"}) -account_id = created["accountid"] +# Create (returns list[str] of new GUIDs) +account_id = client.create("accounts", {"name": "Acme, Inc.", "telephone1": "555-0100"})[0] # Read account = client.get("accounts", account_id) -# Update (returns updated record) -updated = client.update("accounts", account_id, {"telephone1": "555-0199"}) +# Update (returns None) +client.update("accounts", account_id, {"telephone1": "555-0199"}) -# Bulk update (collection-bound UpdateMultiple) -# Each record must include the primary key attribute (accountid). The call returns None. -client.update_multiple("accounts", [ - {"accountid": account_id, "telephone1": "555-0200"}, +# Bulk update (broadcast) – apply same patch to several IDs +ids = client.create("accounts", [ + {"name": "Contoso"}, + {"name": "Fabrikam"}, ]) -print({"bulk_update": "ok"}) +client.update("accounts", ids, {"telephone1": "555-0200"}) # broadcast patch + +# Bulk update (1:1) – list of patches matches list of IDs +client.update("accounts", ids, [ + {"telephone1": "555-1200"}, + {"telephone1": "555-1300"}, +]) +print({"multi_update": "ok"}) # Delete client.delete("accounts", account_id) @@ -119,7 +156,7 @@ for r in rows: ## Bulk create (CreateMultiple) -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. +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. ```python # Bulk create accounts (returns list of GUIDs) @@ -133,34 +170,31 @@ assert isinstance(ids, list) and all(isinstance(x, str) for x in ids) print({"created_ids": ids}) ``` -## Bulk update (UpdateMultiple) +## Bulk update (UpdateMultiple under the hood) -Use `update_multiple(entity_set, records)` for a transactional batch update. The method returns `None`. +Use the unified `update` method for both single and bulk scenarios: ```python -ids = client.create("accounts", [ - {"name": "Fourth Coffee"}, - {"name": "Tailspin"}, -]) +# Broadcast +client.update("accounts", ids, {"telephone1": "555-0200"}) -client.update_multiple("accounts", [ - {"accountid": ids[0], "telephone1": "555-1111"}, - {"accountid": ids[1], "telephone1": "555-2222"}, +# 1:1 patches (length must match) +client.update("accounts", ids, [ + {"telephone1": "555-1200"}, + {"telephone1": "555-1300"}, ]) -print({"bulk_update": "ok"}) ``` Notes: -- Each record must include the primary key attribute (e.g. `accountid`). No `id` alias yet. -- If any payload omits `@odata.type`, the logical name is resolved once and stamped (same as bulk create). -- Entire request fails (HTTP error) if any individual update fails; no partial success list is returned. -- If you need refreshed records post-update, issue individual `get` calls or a `get_multiple` query. +- Returns `None` (same as single update) to keep semantics consistent. +- Broadcast vs per-record determined by whether `changes` is a dict or list. +- Primary key attribute is injected automatically when constructing UpdateMultiple targets. +- If any payload omits `@odata.type`, it's stamped automatically (cached logical name lookup). -Notes: -- The bulk create response typically includes IDs only; the SDK returns the list of GUID strings. -- Single-record `create` still returns the full entity representation. -- `@odata.type` handling: If any payload in the list omits `@odata.type`, the SDK performs a one-time metadata query (`EntityDefinitions?$filter=EntitySetName eq ''`) to resolve the logical name, caches it, and stamps each missing item with `Microsoft.Dynamics.CRM.`. If **all** payloads already include `@odata.type`, no metadata call is made. -- The metadata lookup is per entity set and reused across subsequent multi-create calls in the same client instance (in-memory cache only). +Bulk create notes: +- Response includes only IDs; the SDK returns those GUID strings. +- Single-record `create` returns a one-element list of GUIDs. +- Metadata lookup for `@odata.type` is performed once per entity set (cached in-memory). ## Retrieve multiple with paging @@ -267,7 +301,8 @@ client.delete_table("SampleItem") # delete the table ``` Notes: -- `create/update` return the full record using `Prefer: return=representation`. +- `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. - For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required. @@ -285,7 +320,7 @@ VS Code Tasks - No general-purpose OData batching, upsert, or association operations yet. - `DeleteMultiple` not yet exposed. - Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency. -- Entity naming conventions in Dataverse: for multi-create the SDK resolves logical names from entity set metadata. +- Entity naming conventions in Dataverse: for bulk create the SDK resolves logical names from entity set metadata. ## Contributing diff --git a/examples/quickstart.py b/examples/quickstart.py index e0c9d0b5..834349f4 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -176,33 +176,25 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None: ) record_ids: list[str] = [] -created_recs: list[dict] = [] try: - # Single create (always returns full representation) + # Single create returns list[str] (length 1) log_call(f"client.create('{entity_set}', single_payload)") - # Retry in case the custom table isn't fully provisioned immediately (404) - rec1 = backoff_retry(lambda: client.create(entity_set, single_payload)) - created_recs.append(rec1) - rid1 = rec1.get(id_key) - if rid1: - record_ids.append(rid1) - - # Multi create (list) now returns list[str] of IDs + single_ids = backoff_retry(lambda: client.create(entity_set, single_payload)) + if not (isinstance(single_ids, list) and len(single_ids) == 1): + raise RuntimeError("Unexpected single create return shape (expected one-element list)") + record_ids.extend(single_ids) + + # Multi create returns list[str] log_call(f"client.create('{entity_set}', multi_payloads)") multi_ids = backoff_retry(lambda: client.create(entity_set, multi_payloads)) if isinstance(multi_ids, list): - for mid in multi_ids: - if isinstance(mid, str): - record_ids.append(mid) + record_ids.extend([mid for mid in multi_ids if isinstance(mid, str)]) else: print({"multi_unexpected_type": type(multi_ids).__name__, "value_preview": str(multi_ids)[:300]}) print({"entity": logical, "created_ids": record_ids}) - summaries = [] - for rec in created_recs: - summaries.append({"id": rec.get(id_key), **summary_from_record(rec)}) - print_line_summaries("Created record summaries (single only; multi-create returns IDs only):", summaries) + print_line_summaries("Created record summaries (IDs only; representation not fetched):", [{"id": rid} for rid in record_ids[:1]]) except Exception as e: # Surface detailed info for debugging (especially multi-create failures) print(f"Create failed: {e}") @@ -273,16 +265,16 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None: # Update only the chosen record and summarize log_call(f"client.update('{entity_set}', '{target_id}', update_data)") - new_rec = backoff_retry(lambda: client.update(entity_set, target_id, update_data)) - # Verify string/int/bool fields + # Perform update (returns None); follow-up read to verify + backoff_retry(lambda: client.update(entity_set, target_id, update_data)) + verify_rec = backoff_retry(lambda: client.get(entity_set, target_id)) for k, v in expected_checks.items(): - assert new_rec.get(k) == v, f"Field {k} expected {v}, got {new_rec.get(k)}" - # Verify decimal with tolerance - got = new_rec.get(amount_key) + assert verify_rec.get(k) == v, f"Field {k} expected {v}, got {verify_rec.get(k)}" + got = verify_rec.get(amount_key) got_f = float(got) if got is not None else None assert got_f is not None and abs(got_f - 543.21) < 1e-6, f"Field {amount_key} expected 543.21, got {got}" print({"entity": logical, "updated": True}) - print_line_summaries("Updated record summary:", [{"id": target_id, **summary_from_record(new_rec)}]) + print_line_summaries("Updated record summary:", [{"id": target_id, **summary_from_record(verify_rec)}]) except Exception as e: print(f"Update/verify failed: {e}") sys.exit(1) @@ -300,9 +292,9 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None: id_key: rid, count_key: 100 + idx, # new count values }) - log_call(f"client.update_multiple('{entity_set}', <{len(bulk_updates)} records>)") - # update_multiple returns nothing (fire-and-forget success semantics) - backoff_retry(lambda: client.update_multiple(entity_set, bulk_updates)) + log_call(f"client.update('{entity_set}', <{len(bulk_updates)} ids>, )") + # Unified update handles multiple via list of patches (returns None) + backoff_retry(lambda: client.update(entity_set, subset, bulk_updates)) print({"bulk_update_requested": len(bulk_updates), "bulk_update_completed": True}) # Verify the updated count values by refetching the subset verification = [] diff --git a/src/dataverse_sdk/client.py b/src/dataverse_sdk/client.py index 248b0bd1..83406626 100644 --- a/src/dataverse_sdk/client.py +++ b/src/dataverse_sdk/client.py @@ -62,83 +62,69 @@ def _get_odata(self) -> ODataClient: self._odata = ODataClient(self.auth, self._base_url, self._config) return self._odata - # CRUD - def create(self, entity: str, record_data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[Dict[str, Any], List[str]]: - """Create one or more records. - - Behaviour: - - Single: returns the created record (dict) using Prefer: return=representation. - - Multiple: uses bound CreateMultiple action and returns list[str] of created record IDs. + # ---------------- Unified CRUD: create/update/delete ---------------- + def create(self, entity: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]: + """Create one or many records; always return list[str] of created IDs. Parameters ---------- entity : str - Entity set name (plural logical name), e.g., ``"accounts"``. - record_data : dict | list[dict] - Single record payload or list of records for multi-create. + Entity set name (plural logical name), e.g. "accounts". + records : dict | list[dict] + A single record dict or a list of record dicts. Returns ------- - dict | list[str] - Dict for single create, list of GUID strings for multi-create. - - Raises - ------ - requests.exceptions.HTTPError - If the request fails. - TypeError - If ``record_data`` is not a dict or list of dict. - """ - return self._get_odata().create(entity, record_data) - - def update(self, entity: str, record_id: str, record_data: dict) -> dict: - """Update a record and return its full representation. - - Parameters - ---------- - entity : str - Entity set name (plural logical name). - record_id : str - The record GUID (with or without parentheses). - record_data : dict - Field-value pairs to update. - - Returns - ------- - dict - The updated record payload. + list[str] + List of created GUIDs (length 1 for single input). """ - return self._get_odata().update(entity, record_id, record_data) - - def update_multiple(self, entity: str, records: List[Dict[str, Any]]) -> None: - """Bulk update multiple records via the bound UpdateMultiple action. - - Parameters - ---------- - entity : str - Entity set name (plural logical name). - records : list[dict] - Each record must include the primary key attribute (e.g. ``accountid``) plus fields to update. - - Returns - ------- - None - On success returns nothing. + od = self._get_odata() + if isinstance(records, dict): + rid = od._create_single(entity, records) + if not isinstance(rid, str): + raise TypeError("_create_single did not return GUID string") + return [rid] + if isinstance(records, list): + ids = od._create_multiple(entity, records) + if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids): + raise TypeError("_create_multiple did not return list[str]") + return ids + raise TypeError("records must be dict or list[dict]") + + def update(self, entity: str, ids: Union[str, List[str]], changes: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None: + """Update one or many records. Returns None. + + Usage patterns: + update("accounts", some_id, {"telephone1": "555"}) + update("accounts", [id1, id2], {"statecode": 1}) # broadcast + update("accounts", [id1, id2], [{"name": "A"}, {"name": "B"}]) # 1:1 + + Rules: + - If ids is a list and changes is a single dict -> broadcast. + - If both are lists they must have equal length. + - Single update discards representation (performance-focused). """ - self._get_odata().update_multiple(entity, records) + od = self._get_odata() + if isinstance(ids, str): + if not isinstance(changes, dict): + raise TypeError("For single id, changes must be a dict") + od._update(entity, ids, changes) # discard representation + return None + if not isinstance(ids, list): + raise TypeError("ids must be str or list[str]") + od._update_by_ids(entity, ids, changes) return None - def delete(self, entity: str, record_id: str) -> None: - """Delete a record by ID. - - Parameters - ---------- - entity : str - Entity set name (plural logical name). - record_id : str - The record GUID (with or without parentheses). - """ - self._get_odata().delete(entity, record_id) + def delete(self, entity: str, ids: Union[str, List[str]]) -> None: + """Delete one or many records (GUIDs). Returns None.""" + od = self._get_odata() + if isinstance(ids, str): + od._delete(entity, ids) + return None + if not isinstance(ids, list): + raise TypeError("ids must be str or list[str]") + od._delete_multiple(entity, ids) + return None def get(self, entity: str, record_id: str) -> dict: """Fetch a record by ID. @@ -155,7 +141,7 @@ def get(self, entity: str, record_id: str) -> dict: dict The record JSON payload. """ - return self._get_odata().get(entity, record_id) + return self._get_odata()._get(entity, record_id) def get_multiple( self, @@ -172,7 +158,7 @@ def get_multiple( Yields a list of records per page, following @odata.nextLink until exhausted. Parameters mirror standard OData query options. """ - return self._get_odata().get_multiple( + return self._get_odata()._get_multiple( entity, select=select, filter=filter, @@ -200,7 +186,7 @@ def query_sql(self, sql: str): list[dict] Result rows (empty list if none). """ - return self._get_odata().query_sql(sql) + return self._get_odata()._query_sql(sql) # Table metadata helpers def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]: @@ -218,7 +204,7 @@ def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]: Dict with keys like ``entity_schema``, ``entity_logical_name``, ``entity_set_name``, and ``metadata_id``; ``None`` if not found. """ - return self._get_odata().get_table_info(tablename) + return self._get_odata()._get_table_info(tablename) def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]: """Create a simple custom table. @@ -237,7 +223,7 @@ def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any] Metadata summary including ``entity_schema``, ``entity_set_name``, ``entity_logical_name``, ``metadata_id``, and ``columns_created``. """ - return self._get_odata().create_table(tablename, schema) + return self._get_odata()._create_table(tablename, schema) def delete_table(self, tablename: str) -> None: """Delete a custom table by name. @@ -247,7 +233,7 @@ def delete_table(self, tablename: str) -> None: tablename : str Friendly name (``"SampleItem"``) or a full schema name (``"new_SampleItem"``). """ - self._get_odata().delete_table(tablename) + self._get_odata()._delete_table(tablename) def list_tables(self) -> list[str]: """List all custom tables in the Dataverse environment. @@ -257,8 +243,8 @@ def list_tables(self) -> list[str]: list[str] A list of table names. """ - return self._get_odata().list_tables() + return self._get_odata()._list_tables() __all__ = ["DataverseClient"] - + diff --git a/src/dataverse_sdk/odata.py b/src/dataverse_sdk/odata.py index 6acc60b4..ea8c0d72 100644 --- a/src/dataverse_sdk/odata.py +++ b/src/dataverse_sdk/odata.py @@ -7,6 +7,9 @@ from .http import HttpClient +_GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + class ODataClient: """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" @@ -31,6 +34,10 @@ def __init__(self, auth, base_url: str, config=None) -> None: self._entityset_logical_cache = {} # Cache: logical name -> entity set name (reverse lookup for SQL endpoint) self._logical_to_entityset_cache: dict[str, str] = {} + # Cache: entity set name -> primary id attribute (metadata PrimaryIdAttribute) + self._entityset_primaryid_cache: dict[str, str] = {} + # Cache: logical name -> primary id attribute + self._logical_primaryid_cache: dict[str, str] = {} def _headers(self) -> Dict[str, str]: """Build standard OData headers with bearer auth.""" @@ -48,7 +55,7 @@ def _request(self, method: str, url: str, **kwargs): return self._http.request(method, url, **kwargs) # ----------------------------- CRUD --------------------------------- - def create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[Dict[str, Any], List[str]]: + def _create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[str, List[str]]: """Create one or many records. Parameters @@ -60,7 +67,7 @@ def create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any Behaviour --------- - - Single (dict): POST /{entity_set} with Prefer: return=representation. Returns created record (dict). + - 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 @@ -71,8 +78,8 @@ def create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any Returns ------- - dict | list[str] - Created entity (single) or list of created IDs (multi). + str | list[str] + Created record GUID (single) or list of created IDs (multi). """ if isinstance(data, dict): return self._create_single(entity_set, data) @@ -81,19 +88,31 @@ def create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any raise TypeError("data must be dict or list[dict]") # --- Internal helpers --- - def _create_single(self, entity_set: str, record: Dict[str, Any]) -> Dict[str, Any]: + def _create_single(self, entity_set: 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. + Raises RuntimeError if neither header contains a GUID. + """ url = f"{self.api}/{entity_set}" headers = self._headers().copy() - # Always request the created representation; server may ignore but for single create - # Dataverse typically returns the full body when asked. - headers["Prefer"] = "return=representation" r = self._request("post", url, headers=headers, json=record) r.raise_for_status() - # If empty body, return {} (server might not honour prefer) - try: - return r.json() if r.text else {} - except ValueError: - return {} + + ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID") + if ent_loc: + m = _GUID_RE.search(ent_loc) + if m: + return m.group(0) + loc = r.headers.get("Location") + if loc: + m = _GUID_RE.search(loc) + if m: + return m.group(0) + header_keys = ", ".join(sorted(r.headers.keys())) + raise RuntimeError( + f"Create response missing GUID in OData-EntityId/Location headers (status={getattr(r,'status_code', '?')}). Headers: {header_keys}" + ) def _logical_from_entity_set(self, entity_set: str) -> str: """Resolve logical name from an entity set using metadata (cached).""" @@ -107,7 +126,7 @@ def _logical_from_entity_set(self, entity_set: str) -> str: # Escape single quotes in entity set name es_escaped = self._escape_odata_quotes(es) params = { - "$select": "LogicalName,EntitySetName", + "$select": "LogicalName,EntitySetName,PrimaryIdAttribute", "$filter": f"EntitySetName eq '{es_escaped}'", } r = self._request("get", url, headers=self._headers(), params=params) @@ -119,10 +138,15 @@ def _logical_from_entity_set(self, entity_set: str) -> str: items = [] if not items: raise RuntimeError(f"Unable to resolve logical name for entity set '{es}'. Provide @odata.type explicitly.") - logical = items[0].get("LogicalName") + md = items[0] + logical = md.get("LogicalName") if not logical: raise RuntimeError(f"Metadata response missing LogicalName for entity set '{es}'.") + primary_id_attr = md.get("PrimaryIdAttribute") self._entityset_logical_cache[es] = logical + if isinstance(primary_id_attr, str) and primary_id_attr: + self._entityset_primaryid_cache[es] = primary_id_attr + self._logical_primaryid_cache[logical] = primary_id_attr return logical def _create_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> List[str]: @@ -172,6 +196,59 @@ def _create_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> Li return out return [] + # --- Derived helpers for high-level client ergonomics --- + def _primary_id_attr(self, entity_set: str) -> str: + """Return primary key attribute using metadata (fallback to id).""" + pid = self._entityset_primaryid_cache.get(entity_set) + if pid: + return pid + logical = self._logical_from_entity_set(entity_set) + pid = self._entityset_primaryid_cache.get(entity_set) or self._logical_primaryid_cache.get(logical) + if pid: + return pid + return f"{logical}id" + + def _update_by_ids(self, entity_set: str, ids: List[str], changes: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None: + """Update many records by GUID list using UpdateMultiple under the hood. + + Parameters + ---------- + entity_set : str + Entity set (plural logical name). + ids : list[str] + GUIDs of target records. + changes : dict | list[dict] + Broadcast patch (dict) applied to all IDs, or list of per-record patches (1:1 with ids). + """ + if not isinstance(ids, list): + raise TypeError("ids must be list[str]") + if not ids: + return None + pk_attr = self._primary_id_attr(entity_set) + if isinstance(changes, dict): + batch = [{pk_attr: rid, **changes} for rid in ids] + self._update_multiple(entity_set, batch) + return None + if not isinstance(changes, list): + raise TypeError("changes must be dict or list[dict]") + if len(changes) != len(ids): + raise ValueError("Length of changes list must match length of ids list") + batch: List[Dict[str, Any]] = [] + for rid, patch in zip(ids, changes): + if not isinstance(patch, dict): + raise TypeError("Each patch must be a dict") + batch.append({pk_attr: rid, **patch}) + self._update_multiple(entity_set, batch) + return None + + def _delete_multiple(self, entity_set: str, ids: List[str]) -> None: + """Delete many records by GUID list (simple loop; potential future optimization point).""" + if not isinstance(ids, list): + raise TypeError("ids must be list[str]") + for rid in ids: + self.delete(entity_set, rid) + return None + def _format_key(self, key: str) -> str: k = key.strip() if k.startswith("(") and k.endswith(")"): @@ -187,8 +264,8 @@ def esc(match): return f"({k})" return f"({k})" - def update(self, entity_set: str, key: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Update an existing record and return the updated representation. + def _update(self, entity_set: str, key: str, data: Dict[str, Any]) -> None: + """Update an existing record. Parameters ---------- @@ -201,18 +278,15 @@ def update(self, entity_set: str, key: str, data: Dict[str, Any]) -> Dict[str, A Returns ------- - dict - Updated record representation. + None """ url = f"{self.api}/{entity_set}{self._format_key(key)}" headers = self._headers().copy() headers["If-Match"] = "*" - headers["Prefer"] = "return=representation" r = self._request("patch", url, headers=headers, json=data) r.raise_for_status() - return r.json() - def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> None: + def _update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> None: """Bulk update existing records via the collection-bound UpdateMultiple action. Parameters @@ -227,22 +301,18 @@ def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> Non Behaviour --------- - POST ``/{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple`` with body ``{"Targets": [...]}``. - - Expects Dataverse transactional semantics: if any individual update fails the entire request is rolled back - and an error HTTP status is returned (no partial success handling in V1). - - Response is expected to include an ``Ids`` list (mirrors CreateMultiple); if absent an empty list is - returned. + - Expects Dataverse transactional semantics: if any individual update fails the entire request is rolled back. + - Response content is ignored; no stable contract for returned IDs or representations. Returns ------- None - This method does not return IDs or record bodies. The Dataverse UpdateMultiple action does not - consistently emit identifiers across environments; to keep semantics predictable the SDK returns - nothing on success. Use follow-up queries (e.g. get / get_multiple) if you need refreshed data. + No representation is returned (symmetry with single update). Notes ----- - Caller must include the correct primary key attribute (e.g. ``accountid``) in every record. - - No representation of updated records is returned; for a single record representation use ``update``. + - Both single and multiple updates return None. """ if not isinstance(records, list) or not records or not all(isinstance(r, dict) for r in records): raise TypeError("records must be a non-empty list[dict]") @@ -266,10 +336,10 @@ def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> Non headers = self._headers().copy() r = self._request("post", url, headers=headers, json=payload) r.raise_for_status() - # Intentionally ignore response content: no stable contract for IDs across environments. + # Intentionally ignore response content: no stable contract for IDs across environments. return None - def delete(self, entity_set: str, key: str) -> None: + def _delete(self, entity_set: str, key: str) -> None: """Delete a record by GUID or alternate key.""" url = f"{self.api}/{entity_set}{self._format_key(key)}" headers = self._headers().copy() @@ -277,7 +347,7 @@ def delete(self, entity_set: str, key: str) -> None: r = self._request("delete", url, headers=headers) r.raise_for_status() - def get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[str, Any]: + def _get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[str, Any]: """Retrieve a single record. Parameters @@ -297,7 +367,7 @@ def get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[s r.raise_for_status() return r.json() - def get_multiple( + def _get_multiple( self, entity_set: str, select: Optional[List[str]] = None, @@ -376,7 +446,7 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") if isinstance(data, dict) else None # --------------------------- SQL Custom API ------------------------- - def query_sql(self, sql: str) -> list[dict[str, Any]]: + def _query_sql(self, sql: str) -> list[dict[str, Any]]: """Execute a read-only SQL query using the Dataverse Web API `?sql=` capability. The platform supports a constrained subset of SQL SELECT statements directly on entity set endpoints: @@ -473,7 +543,7 @@ def _entity_set_from_logical(self, logical: str) -> str: url = f"{self.api}/EntityDefinitions" logical_escaped = self._escape_odata_quotes(logical) params = { - "$select": "LogicalName,EntitySetName", + "$select": "LogicalName,EntitySetName,PrimaryIdAttribute", "$filter": f"LogicalName eq '{logical_escaped}'", } r = self._request("get", url, headers=self._headers(), params=params) @@ -485,10 +555,15 @@ def _entity_set_from_logical(self, logical: str) -> str: items = [] if not items: raise RuntimeError(f"Unable to resolve entity set for logical name '{logical}'.") - es = items[0].get("EntitySetName") + md = items[0] + es = md.get("EntitySetName") if not es: raise RuntimeError(f"Metadata response missing EntitySetName for logical '{logical}'.") self._logical_to_entityset_cache[logical] = es + primary_id_attr = md.get("PrimaryIdAttribute") + if isinstance(primary_id_attr, str) and primary_id_attr: + self._logical_primaryid_cache[logical] = primary_id_attr + self._entityset_primaryid_cache[es] = primary_id_attr return es # ---------------------- Table metadata helpers ---------------------- @@ -631,7 +706,7 @@ def _attribute_payload(self, schema_name: str, dtype: str, *, is_primary_name: b } return None - def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]: + def _get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]: """Return basic metadata for a custom table if it exists. Parameters @@ -655,7 +730,7 @@ def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]: "columns_created": [], } - def list_tables(self) -> List[Dict[str, Any]]: + def _list_tables(self) -> List[Dict[str, Any]]: """List all tables in the Dataverse, excluding private tables (IsPrivate=true).""" url = f"{self.api}/EntityDefinitions" params = { @@ -665,7 +740,7 @@ def list_tables(self) -> List[Dict[str, Any]]: r.raise_for_status() return r.json().get("value", []) - def delete_table(self, tablename: str) -> None: + def _delete_table(self, tablename: str) -> None: schema_name = tablename if "_" in tablename else f"new_{self._to_pascal(tablename)}" entity_schema = schema_name ent = self._get_entity_by_schema(entity_schema) @@ -677,7 +752,7 @@ def delete_table(self, tablename: str) -> None: r = self._request("delete", url, headers=headers) r.raise_for_status() - def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]: + def _create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]: # Accept a friendly name and construct a default schema under 'new_'. # If a full SchemaName is passed (contains '_'), use as-is. entity_schema = tablename if "_" in tablename else f"new_{self._to_pascal(tablename)}" diff --git a/src/dataverse_sdk/odata_pandas_wrappers.py b/src/dataverse_sdk/odata_pandas_wrappers.py index 86cb3afc..06e9e102 100644 --- a/src/dataverse_sdk/odata_pandas_wrappers.py +++ b/src/dataverse_sdk/odata_pandas_wrappers.py @@ -72,18 +72,14 @@ def create_df(self, entity_set: str, record: pd.Series) -> str: if not isinstance(record, pd.Series): raise TypeError("record must be a pandas Series") payload = {k: v for k, v in record.items()} - created = self._c.create(entity_set, payload) - # Extract primary id from returned representation (first '*id' that looks like a GUID) - if isinstance(created, dict): - for k, v in created.items(): - if isinstance(k, str) and k.lower().endswith("id") and isinstance(v, (str,)): - if re.fullmatch(r"[0-9a-fA-F-]{36}", v.strip() or ""): - return v - raise RuntimeError("Could not determine created record id from returned representation") + created_ids = self._c.create(entity_set, payload) + if not isinstance(created_ids, list) or len(created_ids) != 1 or not isinstance(created_ids[0], str): + raise RuntimeError("Unexpected create return shape (expected single-element list of GUID str)") + return created_ids[0] # ---------------------------- Update --------------------------------- def update(self, entity_set: str, record_id: str, entity_data: pd.Series) -> None: - """Update a single record. + """Update a single record (returns None). Parameters ---------- diff --git a/tests/test_create_single_guid.py b/tests/test_create_single_guid.py new file mode 100644 index 00000000..b9a24704 --- /dev/null +++ b/tests/test_create_single_guid.py @@ -0,0 +1,46 @@ +import types +from dataverse_sdk.odata import ODataClient, _GUID_RE + +class DummyAuth: + def acquire_token(self, scope): + class T: access_token = "x" + return T() + +class DummyHTTP: + def __init__(self, headers): + self._headers = headers + def request(self, method, url, **kwargs): + # Simulate minimal Response-like object + resp = types.SimpleNamespace() + resp.headers = self._headers + resp.status_code = 204 + def raise_for_status(): + return None + resp.raise_for_status = raise_for_status + return resp + +class TestableOData(ODataClient): + def __init__(self, headers): + super().__init__(DummyAuth(), "https://org.example", None) + # Monkey-patch http client + self._http = types.SimpleNamespace(request=lambda method, url, **kwargs: DummyHTTP(headers).request(method, url, **kwargs)) + +def test__create_single_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) + result = c._create_single("accounts", {"name": "x"}) + assert result == guid + +def test__create_single_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", {"firstname": "x"}) + assert result == guid + +def test__create_single_missing_headers_raises(): + c = TestableOData({}) + import pytest + with pytest.raises(RuntimeError): + c._create_single("accounts", {"name": "x"})