@@ -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