Skip to content

Commit 3081914

Browse files
committed
PR fixes
1 parent a0ba56d commit 3081914

4 files changed

Lines changed: 67 additions & 51 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ Auth:
3535
| `get` | `get(entity_set, id)` | `dict` | One record; supply GUID (with/without parentheses). |
3636
| `get_multiple` | `get_multiple(entity_set, ..., page_size=None)` | `Iterable[list[dict]]` | Pages yielded (non-empty only). |
3737
| `update` | `update(entity_set, id, patch)` | `None` | Single update; no representation returned. |
38-
| `update` | `update(entity_set, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs. |
39-
| `update` | `update(entity_set, list[id], list[patch])` | `None` | 1:1 patches; lengths must match. |
38+
| `update` | `update(entity_set, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs. Calls UpdateMultiple web API internally. |
39+
| `update` | `update(entity_set, list[id], list[patch])` | `None` | 1:1 patches; lengths must match. Calls UpdateMultiple web API internally. |
4040
| `delete` | `delete(entity_set, id)` | `None` | Delete one record. |
4141
| `delete` | `delete(entity_set, list[id])` | `None` | Delete many (sequential). |
4242
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
@@ -50,9 +50,9 @@ Auth:
5050
| `PandasODataClient.query_sql_df` | `query_sql_df(sql)` | `DataFrame` | DataFrame for SQL results. |
5151

5252
Guidelines:
53-
- `create` always returns a list of GUIDs (1 for single, N for multi).
53+
- `create` always returns a list of GUIDs (1 for single, N for bulk).
5454
- `update`/`delete` always return `None` (single and multi forms).
55-
- Multi-update chooses broadcast vs per-record by the type of `changes` (dict vs list).
55+
- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list).
5656
- Paging and SQL operations never mutate inputs.
5757
- Metadata lookups for logical name stamping cached per entity set (in-memory).
5858

@@ -132,14 +132,14 @@ account = client.get("accounts", account_id)
132132
# Update (returns None)
133133
client.update("accounts", account_id, {"telephone1": "555-0199"})
134134

135-
# Multi-update (broadcast) – apply same patch to several IDs
135+
# Bulk update (broadcast) – apply same patch to several IDs
136136
ids = client.create("accounts", [
137137
{"name": "Contoso"},
138138
{"name": "Fabrikam"},
139139
])
140140
client.update("accounts", ids, {"telephone1": "555-0200"}) # broadcast patch
141141

142-
# Multi-update (1:1) – list of patches matches list of IDs
142+
# Bulk update (1:1) – list of patches matches list of IDs
143143
client.update("accounts", ids, [
144144
{"telephone1": "555-1200"},
145145
{"telephone1": "555-1300"},
@@ -170,7 +170,7 @@ assert isinstance(ids, list) and all(isinstance(x, str) for x in ids)
170170
print({"created_ids": ids})
171171
```
172172

173-
## Multi-update (UpdateMultiple under the hood)
173+
## Bulk update (UpdateMultiple under the hood)
174174

175175
Use the unified `update` method for both single and bulk scenarios:
176176

@@ -320,7 +320,7 @@ VS Code Tasks
320320
- No general-purpose OData batching, upsert, or association operations yet.
321321
- `DeleteMultiple` not yet exposed.
322322
- Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency.
323-
- Entity naming conventions in Dataverse: for multi-create the SDK resolves logical names from entity set metadata.
323+
- Entity naming conventions in Dataverse: for bulk create the SDK resolves logical names from entity set metadata.
324324

325325
## Contributing
326326

src/dataverse_sdk/client.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,15 @@ def create(self, entity: str, records: Union[Dict[str, Any], List[Dict[str, Any]
8080
"""
8181
od = self._get_odata()
8282
if isinstance(records, dict):
83-
rid = od.create(entity, records) # low-level guarantees GUID str for single
83+
rid = od._create_single(entity, records)
8484
if not isinstance(rid, str):
85-
raise TypeError("Low-level create did not return GUID string for single record")
85+
raise TypeError("_create_single did not return GUID string")
8686
return [rid]
8787
if isinstance(records, list):
88-
return od.create(entity, records) # multi path returns list[str]
88+
ids = od._create_multiple(entity, records)
89+
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
90+
raise TypeError("_create_multiple did not return list[str]")
91+
return ids
8992
raise TypeError("records must be dict or list[dict]")
9093

9194
def update(self, entity: str, ids: Union[str, List[str]], changes: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None:
@@ -105,22 +108,22 @@ def update(self, entity: str, ids: Union[str, List[str]], changes: Union[Dict[st
105108
if isinstance(ids, str):
106109
if not isinstance(changes, dict):
107110
raise TypeError("For single id, changes must be a dict")
108-
od.update(entity, ids, changes) # discard representation
111+
od._update(entity, ids, changes) # discard representation
109112
return None
110113
if not isinstance(ids, list):
111114
raise TypeError("ids must be str or list[str]")
112-
od.update_by_ids(entity, ids, changes)
115+
od._update_by_ids(entity, ids, changes)
113116
return None
114117

115118
def delete(self, entity: str, ids: Union[str, List[str]]) -> None:
116119
"""Delete one or many records (GUIDs). Returns None."""
117120
od = self._get_odata()
118121
if isinstance(ids, str):
119-
od.delete(entity, ids)
122+
od._delete(entity, ids)
120123
return None
121124
if not isinstance(ids, list):
122125
raise TypeError("ids must be str or list[str]")
123-
od.delete_many(entity, ids)
126+
od._delete_multiple(entity, ids)
124127
return None
125128

126129
def get(self, entity: str, record_id: str) -> dict:
@@ -138,7 +141,7 @@ def get(self, entity: str, record_id: str) -> dict:
138141
dict
139142
The record JSON payload.
140143
"""
141-
return self._get_odata().get(entity, record_id)
144+
return self._get_odata()._get(entity, record_id)
142145

143146
def get_multiple(
144147
self,
@@ -155,7 +158,7 @@ def get_multiple(
155158
Yields a list of records per page, following @odata.nextLink until exhausted.
156159
Parameters mirror standard OData query options.
157160
"""
158-
return self._get_odata().get_multiple(
161+
return self._get_odata()._get_multiple(
159162
entity,
160163
select=select,
161164
filter=filter,
@@ -183,7 +186,7 @@ def query_sql(self, sql: str):
183186
list[dict]
184187
Result rows (empty list if none).
185188
"""
186-
return self._get_odata().query_sql(sql)
189+
return self._get_odata()._query_sql(sql)
187190

188191
# Table metadata helpers
189192
def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
@@ -201,7 +204,7 @@ def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
201204
Dict with keys like ``entity_schema``, ``entity_logical_name``,
202205
``entity_set_name``, and ``metadata_id``; ``None`` if not found.
203206
"""
204-
return self._get_odata().get_table_info(tablename)
207+
return self._get_odata()._get_table_info(tablename)
205208

206209
def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]:
207210
"""Create a simple custom table.
@@ -220,7 +223,7 @@ def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]
220223
Metadata summary including ``entity_schema``, ``entity_set_name``,
221224
``entity_logical_name``, ``metadata_id``, and ``columns_created``.
222225
"""
223-
return self._get_odata().create_table(tablename, schema)
226+
return self._get_odata()._create_table(tablename, schema)
224227

225228
def delete_table(self, tablename: str) -> None:
226229
"""Delete a custom table by name.
@@ -230,7 +233,7 @@ def delete_table(self, tablename: str) -> None:
230233
tablename : str
231234
Friendly name (``"SampleItem"``) or a full schema name (``"new_SampleItem"``).
232235
"""
233-
self._get_odata().delete_table(tablename)
236+
self._get_odata()._delete_table(tablename)
234237

235238
def list_tables(self) -> list[str]:
236239
"""List all custom tables in the Dataverse environment.
@@ -240,8 +243,8 @@ def list_tables(self) -> list[str]:
240243
list[str]
241244
A list of table names.
242245
"""
243-
return self._get_odata().list_tables()
246+
return self._get_odata()._list_tables()
244247

245248

246249
__all__ = ["DataverseClient"]
247-
250+

src/dataverse_sdk/odata.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def __init__(self, auth, base_url: str, config=None) -> None:
3434
self._entityset_logical_cache = {}
3535
# Cache: logical name -> entity set name (reverse lookup for SQL endpoint)
3636
self._logical_to_entityset_cache: dict[str, str] = {}
37+
# Cache: entity set name -> primary id attribute (metadata PrimaryIdAttribute)
38+
self._entityset_primaryid_cache: dict[str, str] = {}
39+
# Cache: logical name -> primary id attribute
40+
self._logical_primaryid_cache: dict[str, str] = {}
3741

3842
def _headers(self) -> Dict[str, str]:
3943
"""Build standard OData headers with bearer auth."""
@@ -51,7 +55,7 @@ def _request(self, method: str, url: str, **kwargs):
5155
return self._http.request(method, url, **kwargs)
5256

5357
# ----------------------------- CRUD ---------------------------------
54-
def create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[str, List[str]]:
58+
def _create(self, entity_set: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[str, List[str]]:
5559
"""Create one or many records.
5660
5761
Parameters
@@ -122,7 +126,7 @@ def _logical_from_entity_set(self, entity_set: str) -> str:
122126
# Escape single quotes in entity set name
123127
es_escaped = self._escape_odata_quotes(es)
124128
params = {
125-
"$select": "LogicalName,EntitySetName",
129+
"$select": "LogicalName,EntitySetName,PrimaryIdAttribute",
126130
"$filter": f"EntitySetName eq '{es_escaped}'",
127131
}
128132
r = self._request("get", url, headers=self._headers(), params=params)
@@ -134,10 +138,15 @@ def _logical_from_entity_set(self, entity_set: str) -> str:
134138
items = []
135139
if not items:
136140
raise RuntimeError(f"Unable to resolve logical name for entity set '{es}'. Provide @odata.type explicitly.")
137-
logical = items[0].get("LogicalName")
141+
md = items[0]
142+
logical = md.get("LogicalName")
138143
if not logical:
139144
raise RuntimeError(f"Metadata response missing LogicalName for entity set '{es}'.")
145+
primary_id_attr = md.get("PrimaryIdAttribute")
140146
self._entityset_logical_cache[es] = logical
147+
if isinstance(primary_id_attr, str) and primary_id_attr:
148+
self._entityset_primaryid_cache[es] = primary_id_attr
149+
self._logical_primaryid_cache[logical] = primary_id_attr
141150
return logical
142151

143152
def _create_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> List[str]:
@@ -189,15 +198,17 @@ def _create_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> Li
189198

190199
# --- Derived helpers for high-level client ergonomics ---
191200
def _primary_id_attr(self, entity_set: str) -> str:
192-
"""Return the primary id attribute name for an entity set.
193-
194-
Currently derived as <logicalname>id (e.g. account -> accountid).
195-
Centralizing here allows future enhancement (metadata-driven or alternate key support).
196-
"""
201+
"""Return primary key attribute using metadata (fallback to <logical>id)."""
202+
pid = self._entityset_primaryid_cache.get(entity_set)
203+
if pid:
204+
return pid
197205
logical = self._logical_from_entity_set(entity_set)
206+
pid = self._entityset_primaryid_cache.get(entity_set) or self._logical_primaryid_cache.get(logical)
207+
if pid:
208+
return pid
198209
return f"{logical}id"
199210

200-
def update_by_ids(self, entity_set: str, ids: List[str], changes: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None:
211+
def _update_by_ids(self, entity_set: str, ids: List[str], changes: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None:
201212
"""Update many records by GUID list using UpdateMultiple under the hood.
202213
203214
Parameters
@@ -216,7 +227,7 @@ def update_by_ids(self, entity_set: str, ids: List[str], changes: Union[Dict[str
216227
pk_attr = self._primary_id_attr(entity_set)
217228
if isinstance(changes, dict):
218229
batch = [{pk_attr: rid, **changes} for rid in ids]
219-
self.update_multiple(entity_set, batch)
230+
self._update_multiple(entity_set, batch)
220231
return None
221232
if not isinstance(changes, list):
222233
raise TypeError("changes must be dict or list[dict]")
@@ -227,10 +238,10 @@ def update_by_ids(self, entity_set: str, ids: List[str], changes: Union[Dict[str
227238
if not isinstance(patch, dict):
228239
raise TypeError("Each patch must be a dict")
229240
batch.append({pk_attr: rid, **patch})
230-
self.update_multiple(entity_set, batch)
241+
self._update_multiple(entity_set, batch)
231242
return None
232243

233-
def delete_many(self, entity_set: str, ids: List[str]) -> None:
244+
def _delete_multiple(self, entity_set: str, ids: List[str]) -> None:
234245
"""Delete many records by GUID list (simple loop; potential future optimization point)."""
235246
if not isinstance(ids, list):
236247
raise TypeError("ids must be list[str]")
@@ -253,7 +264,7 @@ def esc(match):
253264
return f"({k})"
254265
return f"({k})"
255266

256-
def update(self, entity_set: str, key: str, data: Dict[str, Any]) -> None:
267+
def _update(self, entity_set: str, key: str, data: Dict[str, Any]) -> None:
257268
"""Update an existing record.
258269
259270
Parameters
@@ -275,7 +286,7 @@ def update(self, entity_set: str, key: str, data: Dict[str, Any]) -> None:
275286
r = self._request("patch", url, headers=headers, json=data)
276287
r.raise_for_status()
277288

278-
def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> None:
289+
def _update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> None:
279290
"""Bulk update existing records via the collection-bound UpdateMultiple action.
280291
281292
Parameters
@@ -325,18 +336,18 @@ def update_multiple(self, entity_set: str, records: List[Dict[str, Any]]) -> Non
325336
headers = self._headers().copy()
326337
r = self._request("post", url, headers=headers, json=payload)
327338
r.raise_for_status()
328-
# Intentionally ignore response content: no stable contract for IDs across environments.
339+
# Intentionally ignore response content: no stable contract for IDs across environments.
329340
return None
330341

331-
def delete(self, entity_set: str, key: str) -> None:
342+
def _delete(self, entity_set: str, key: str) -> None:
332343
"""Delete a record by GUID or alternate key."""
333344
url = f"{self.api}/{entity_set}{self._format_key(key)}"
334345
headers = self._headers().copy()
335346
headers["If-Match"] = "*"
336347
r = self._request("delete", url, headers=headers)
337348
r.raise_for_status()
338349

339-
def get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[str, Any]:
350+
def _get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[str, Any]:
340351
"""Retrieve a single record.
341352
342353
Parameters
@@ -356,7 +367,7 @@ def get(self, entity_set: str, key: str, select: Optional[str] = None) -> Dict[s
356367
r.raise_for_status()
357368
return r.json()
358369

359-
def get_multiple(
370+
def _get_multiple(
360371
self,
361372
entity_set: str,
362373
select: Optional[List[str]] = None,
@@ -435,7 +446,7 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
435446
next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") if isinstance(data, dict) else None
436447

437448
# --------------------------- SQL Custom API -------------------------
438-
def query_sql(self, sql: str) -> list[dict[str, Any]]:
449+
def _query_sql(self, sql: str) -> list[dict[str, Any]]:
439450
"""Execute a read-only SQL query using the Dataverse Web API `?sql=` capability.
440451
441452
The platform supports a constrained subset of SQL SELECT statements directly on entity set endpoints:
@@ -532,7 +543,7 @@ def _entity_set_from_logical(self, logical: str) -> str:
532543
url = f"{self.api}/EntityDefinitions"
533544
logical_escaped = self._escape_odata_quotes(logical)
534545
params = {
535-
"$select": "LogicalName,EntitySetName",
546+
"$select": "LogicalName,EntitySetName,PrimaryIdAttribute",
536547
"$filter": f"LogicalName eq '{logical_escaped}'",
537548
}
538549
r = self._request("get", url, headers=self._headers(), params=params)
@@ -544,10 +555,15 @@ def _entity_set_from_logical(self, logical: str) -> str:
544555
items = []
545556
if not items:
546557
raise RuntimeError(f"Unable to resolve entity set for logical name '{logical}'.")
547-
es = items[0].get("EntitySetName")
558+
md = items[0]
559+
es = md.get("EntitySetName")
548560
if not es:
549561
raise RuntimeError(f"Metadata response missing EntitySetName for logical '{logical}'.")
550562
self._logical_to_entityset_cache[logical] = es
563+
primary_id_attr = md.get("PrimaryIdAttribute")
564+
if isinstance(primary_id_attr, str) and primary_id_attr:
565+
self._logical_primaryid_cache[logical] = primary_id_attr
566+
self._entityset_primaryid_cache[es] = primary_id_attr
551567
return es
552568

553569
# ---------------------- Table metadata helpers ----------------------
@@ -690,7 +706,7 @@ def _attribute_payload(self, schema_name: str, dtype: str, *, is_primary_name: b
690706
}
691707
return None
692708

693-
def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
709+
def _get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
694710
"""Return basic metadata for a custom table if it exists.
695711
696712
Parameters
@@ -714,7 +730,7 @@ def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
714730
"columns_created": [],
715731
}
716732

717-
def list_tables(self) -> List[Dict[str, Any]]:
733+
def _list_tables(self) -> List[Dict[str, Any]]:
718734
"""List all tables in the Dataverse, excluding private tables (IsPrivate=true)."""
719735
url = f"{self.api}/EntityDefinitions"
720736
params = {
@@ -724,7 +740,7 @@ def list_tables(self) -> List[Dict[str, Any]]:
724740
r.raise_for_status()
725741
return r.json().get("value", [])
726742

727-
def delete_table(self, tablename: str) -> None:
743+
def _delete_table(self, tablename: str) -> None:
728744
schema_name = tablename if "_" in tablename else f"new_{self._to_pascal(tablename)}"
729745
entity_schema = schema_name
730746
ent = self._get_entity_by_schema(entity_schema)
@@ -736,7 +752,7 @@ def delete_table(self, tablename: str) -> None:
736752
r = self._request("delete", url, headers=headers)
737753
r.raise_for_status()
738754

739-
def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]:
755+
def _create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]:
740756
# Accept a friendly name and construct a default schema under 'new_'.
741757
# If a full SchemaName is passed (contains '_'), use as-is.
742758
entity_schema = tablename if "_" in tablename else f"new_{self._to_pascal(tablename)}"

0 commit comments

Comments
 (0)