@@ -160,8 +160,7 @@ def __init__(
160160 self ._logical_to_entityset_cache : dict [str , str ] = {}
161161 # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid)
162162 self ._logical_primaryid_cache : dict [str , str ] = {}
163- # Picklist label cache: (normalized_table_schema_name, normalized_attribute) -> {'map': {...}, 'ts': epoch_seconds}
164- self ._picklist_label_cache = {}
163+ self ._picklist_label_cache : dict [str , dict ] = {}
165164 self ._picklist_cache_ttl_seconds = 3600 # 1 hour TTL
166165
167166 @contextmanager
@@ -1217,7 +1216,7 @@ def _normalize_picklist_label(self, label: str) -> str:
12171216 return norm
12181217
12191218 def _request_metadata_with_retry (self , method : str , url : str , ** kwargs ):
1220- """Fetch metadata with retries on transient 404 responses ."""
1219+ """Fetch metadata with retries on transient errors ."""
12211220 max_attempts = 5
12221221 backoff_seconds = 0.4
12231222 for attempt in range (1 , max_attempts + 1 ):
@@ -1232,154 +1231,98 @@ def _request_metadata_with_retry(self, method: str, url: str, **kwargs):
12321231 raise
12331232 raise RuntimeError (f"Metadata request failed: { url } " )
12341233
1235- def _check_attribute_types (self , table_schema_name : str , attr_logicals : List [ str ] ) -> None :
1236- """Batch-check AttributeType for multiple attributes in one API call.
1234+ def _bulk_fetch_picklists (self , table_schema_name : str ) -> None :
1235+ """Fetch all picklist attributes and their options for a table in one API call.
12371236
1238- Uses ``Microsoft.Dynamics.CRM.In`` to check all given attributes at once.
1239- Non-picklist attributes (and attributes not found) are cached with an
1240- empty map. Picklist attributes are cached with ``{"type": "Picklist"}``
1241- so that ``_optionset_map`` knows to fetch their options.
1237+ Uses collection-level PicklistAttributeMetadata cast to retrieve every picklist
1238+ attribute on the table, including its OptionSet options. Populates the nested
1239+ cache so that ``_convert_labels_to_ints`` resolves labels without further API calls.
12421240 """
1243- if not attr_logicals :
1241+ table_key = self ._normalize_cache_key (table_schema_name )
1242+ now = time .time ()
1243+ table_entry = self ._picklist_label_cache .get (table_key )
1244+ if isinstance (table_entry , dict ) and (now - table_entry .get ("ts" , 0 )) < self ._picklist_cache_ttl_seconds :
12441245 return
1246+
12451247 table_esc = self ._escape_odata_quotes (table_schema_name .lower ())
1246- quoted_values = "," .join (f'"{ self ._escape_odata_quotes (a .lower ())} "' for a in attr_logicals )
1247- attr_filter = f"Microsoft.Dynamics.CRM.In(PropertyName='LogicalName',PropertyValues=[{ quoted_values } ])"
12481248 url = (
1249- f"{ self .api } /EntityDefinitions(LogicalName='{ table_esc } ')/Attributes"
1250- f"?$filter={ attr_filter } &$select=LogicalName,AttributeType"
1249+ f"{ self .api } /EntityDefinitions(LogicalName='{ table_esc } ')"
1250+ f"/Attributes/Microsoft.Dynamics.CRM.PicklistAttributeMetadata"
1251+ f"?$select=LogicalName&$expand=OptionSet($select=Options)"
12511252 )
1252- r = self ._request_metadata_with_retry ("get" , url )
1253- body = r .json ()
1253+ response = self ._request_metadata_with_retry ("get" , url )
1254+ body = response .json ()
12541255 items = body .get ("value" , []) if isinstance (body , dict ) else []
1255- now = time . time ()
1256- found : set [str ] = set ()
1256+
1257+ picklists : Dict [str , Dict [ str , int ]] = {}
12571258 for item in items :
12581259 if not isinstance (item , dict ):
12591260 continue
12601261 ln = item .get ("LogicalName" , "" ).lower ()
1261- atype = item .get ("AttributeType" , "" )
1262- found .add (ln )
1263- if atype not in ("Picklist" , "PickList" ):
1264- cache_key = (self ._normalize_cache_key (table_schema_name ), ln )
1265- self ._picklist_label_cache [cache_key ] = {"map" : {}, "ts" : now }
1266- else :
1267- cache_key = (self ._normalize_cache_key (table_schema_name ), ln )
1268- self ._picklist_label_cache [cache_key ] = {"type" : "Picklist" , "ts" : now }
1269- # Attributes not in the response don't exist on the table -- cache them too
1270- for a in attr_logicals :
1271- if a .lower () not in found :
1272- cache_key = (self ._normalize_cache_key (table_schema_name ), a .lower ())
1273- self ._picklist_label_cache [cache_key ] = {"map" : {}, "ts" : now }
1274-
1275- def _optionset_map (self , table_schema_name : str , attr_logical : str ) -> Optional [Dict [str , int ]]:
1276- """Build or return cached mapping of normalized label -> value for a picklist attribute.
1277-
1278- Returns empty dict if attribute is not a picklist or has no options. Returns None only
1279- for invalid inputs or unexpected metadata parse failures.
1280- """
1281- if not table_schema_name or not attr_logical :
1282- return None
1283-
1284- # Normalize cache key for case-insensitive lookups
1285- cache_key = (self ._normalize_cache_key (table_schema_name ), self ._normalize_cache_key (attr_logical ))
1286- now = time .time ()
1287- entry = self ._picklist_label_cache .get (cache_key )
1288- if isinstance (entry , dict ) and "map" in entry and (now - entry .get ("ts" , 0 )) < self ._picklist_cache_ttl_seconds :
1289- return entry ["map" ]
1290-
1291- # LogicalNames in Dataverse are stored in lowercase, so we need to lowercase for filters
1292- attr_esc = self ._escape_odata_quotes (attr_logical .lower ())
1293- table_schema_name_esc = self ._escape_odata_quotes (table_schema_name .lower ())
1294-
1295- # Fetch OptionSet values for this picklist attribute
1296- cast_url = (
1297- f"{ self .api } /EntityDefinitions(LogicalName='{ table_schema_name_esc } ')/Attributes(LogicalName='{ attr_esc } ')/"
1298- "Microsoft.Dynamics.CRM.PicklistAttributeMetadata?$select=LogicalName&$expand=OptionSet($select=Options)"
1299- )
1300- r_opts = self ._request_metadata_with_retry ("get" , cast_url )
1301-
1302- attr_full = {}
1303- try :
1304- attr_full = r_opts .json () if r_opts .text else {}
1305- except ValueError :
1306- return None
1307- option_set = attr_full .get ("OptionSet" ) or {}
1308- options = option_set .get ("Options" ) if isinstance (option_set , dict ) else None
1309- if not isinstance (options , list ):
1310- return None
1311- mapping : Dict [str , int ] = {}
1312- for opt in options :
1313- if not isinstance (opt , dict ):
1262+ if not ln :
13141263 continue
1315- val = opt .get ("Value" )
1316- if not isinstance (val , int ):
1317- continue
1318- label_def = opt .get ("Label" ) or {}
1319- locs = label_def .get ("LocalizedLabels" )
1320- if isinstance (locs , list ):
1321- for loc in locs :
1322- if isinstance (loc , dict ):
1323- lab = loc .get ("Label" )
1324- if isinstance (lab , str ) and lab .strip ():
1325- normalized = self ._normalize_picklist_label (lab )
1326- mapping .setdefault (normalized , val )
1327- if mapping :
1328- self ._picklist_label_cache [cache_key ] = {"map" : mapping , "ts" : now }
1329- return mapping
1330- # No options available
1331- self ._picklist_label_cache [cache_key ] = {"map" : {}, "ts" : now }
1332- return {}
1264+ option_set = item .get ("OptionSet" ) or {}
1265+ options = option_set .get ("Options" ) if isinstance (option_set , dict ) else None
1266+ mapping : Dict [str , int ] = {}
1267+ if isinstance (options , list ):
1268+ for opt in options :
1269+ if not isinstance (opt , dict ):
1270+ continue
1271+ val = opt .get ("Value" )
1272+ if not isinstance (val , int ):
1273+ continue
1274+ label_def = opt .get ("Label" ) or {}
1275+ locs = label_def .get ("LocalizedLabels" )
1276+ if isinstance (locs , list ):
1277+ for loc in locs :
1278+ if isinstance (loc , dict ):
1279+ lab = loc .get ("Label" )
1280+ if isinstance (lab , str ) and lab .strip ():
1281+ normalized = self ._normalize_picklist_label (lab )
1282+ mapping .setdefault (normalized , val )
1283+ picklists [ln ] = mapping
1284+
1285+ self ._picklist_label_cache [table_key ] = {"ts" : now , "picklists" : picklists }
13331286
13341287 def _convert_labels_to_ints (self , table_schema_name : str , record : Dict [str , Any ]) -> Dict [str , Any ]:
13351288 """Return a copy of record with any labels converted to option ints.
13361289
13371290 Heuristic: For each string value, attempt to resolve against picklist metadata.
13381291 If attribute isn't a picklist or label not found, value left unchanged.
13391292
1340- Performance: collects all string-valued fields with a cold cache and
1341- issues a single batch type-check using ``Microsoft.Dynamics.CRM.In``
1342- before resolving individual picklist options.
1293+ On first encounter of a table, bulk-fetches all picklist attributes and
1294+ their options in a single API call, then resolves labels from the warm cache.
13431295 """
13441296 resolved_record = record .copy ()
1345- # Collect candidate string-valued attribute names
1346- candidates : List [str ] = []
1297+
1298+ # Check if there are any string-valued candidates worth resolving
1299+ has_candidates = any (
1300+ isinstance (v , str ) and v .strip () and isinstance (k , str ) and "@odata." not in k
1301+ for k , v in resolved_record .items ()
1302+ )
1303+ if not has_candidates :
1304+ return resolved_record
1305+
1306+ # Bulk-fetch all picklists for this table (1 API call, cached for TTL)
1307+ self ._bulk_fetch_picklists (table_schema_name )
1308+
1309+ # Resolve labels from the nested cache
1310+ table_key = self ._normalize_cache_key (table_schema_name )
1311+ table_entry = self ._picklist_label_cache .get (table_key )
1312+ if not isinstance (table_entry , dict ):
1313+ return resolved_record
1314+ picklists = table_entry .get ("picklists" , {})
1315+
13471316 for k , v in resolved_record .items ():
13481317 if not isinstance (v , str ) or not v .strip ():
13491318 continue
13501319 if isinstance (k , str ) and "@odata." in k :
13511320 continue
1352- candidates .append (k )
1353- if not candidates :
1354- return resolved_record
1355-
1356- # Determine which candidates need a type-check (not yet cached)
1357- now = time .time ()
1358- uncached_attrs : List [str ] = []
1359- for attr in candidates :
1360- cache_key = (self ._normalize_cache_key (table_schema_name ), self ._normalize_cache_key (attr ))
1361- entry = self ._picklist_label_cache .get (cache_key )
1362- if not (
1363- isinstance (entry , dict )
1364- and "map" in entry
1365- and (now - entry .get ("ts" , 0 )) < self ._picklist_cache_ttl_seconds
1366- ):
1367- uncached_attrs .append (attr )
1368-
1369- # Batch type-check uncached attributes in one API call
1370- if uncached_attrs :
1371- self ._check_attribute_types (table_schema_name , uncached_attrs )
1372-
1373- # Only call _optionset_map for picklists
1374- for k in candidates :
1375- cache_key = (self ._normalize_cache_key (table_schema_name ), self ._normalize_cache_key (k ))
1376- entry = self ._picklist_label_cache .get (cache_key )
1377- if not (isinstance (entry , dict ) and (entry .get ("type" ) == "Picklist" or entry .get ("map" ))):
1378- continue
1379- mapping = self ._optionset_map (table_schema_name , k )
1380- if not mapping :
1321+ attr_key = self ._normalize_cache_key (k )
1322+ mapping = picklists .get (attr_key )
1323+ if not isinstance (mapping , dict ) or not mapping :
13811324 continue
1382- norm = self ._normalize_picklist_label (resolved_record [ k ] )
1325+ norm = self ._normalize_picklist_label (v )
13831326 val = mapping .get (norm )
13841327 if val is not None :
13851328 resolved_record [k ] = val
0 commit comments