@@ -160,8 +160,11 @@ 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+ # Picklist label cache (nested):
164+ # {table_key: {"ts": epoch, "picklists": {attr: {norm_label: int} | None}}}
165+ # None value for an attr means "confirmed picklist, options not yet fetched"
166+ # Empty dict {} means "confirmed non-picklist" (no options to resolve)
167+ self ._picklist_label_cache : dict [str , dict ] = {}
165168 self ._picklist_cache_ttl_seconds = 3600 # 1 hour TTL
166169
167170 @contextmanager
@@ -1232,154 +1235,99 @@ def _request_metadata_with_retry(self, method: str, url: str, **kwargs):
12321235 raise
12331236 raise RuntimeError (f"Metadata request failed: { url } " )
12341237
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.
1238+ def _bulk_fetch_picklists (self , table_schema_name : str ) -> None :
1239+ """Fetch all picklist attributes and their options for a table in one API call.
12371240
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.
1241+ Uses collection-level PicklistAttributeMetadata cast to retrieve every picklist
1242+ attribute on the table, including its OptionSet options. Populates the nested
1243+ cache so that ``_convert_labels_to_ints`` resolves labels without further API calls.
12421244 """
1243- if not attr_logicals :
1245+ table_key = self ._normalize_cache_key (table_schema_name )
1246+ now = time .time ()
1247+ table_entry = self ._picklist_label_cache .get (table_key )
1248+ if isinstance (table_entry , dict ) and (now - table_entry .get ("ts" , 0 )) < self ._picklist_cache_ttl_seconds :
12441249 return
1250+
12451251 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 } ])"
12481252 url = (
1249- f"{ self .api } /EntityDefinitions(LogicalName='{ table_esc } ')/Attributes"
1250- f"?$filter={ attr_filter } &$select=LogicalName,AttributeType"
1253+ f"{ self .api } /EntityDefinitions(LogicalName='{ table_esc } ')"
1254+ f"/Attributes/Microsoft.Dynamics.CRM.PicklistAttributeMetadata"
1255+ f"?$select=LogicalName&$expand=OptionSet($select=Options)"
12511256 )
1252- r = self ._request_metadata_with_retry ("get" , url )
1253- body = r .json ()
1257+ response = self ._request_metadata_with_retry ("get" , url )
1258+ body = response .json ()
12541259 items = body .get ("value" , []) if isinstance (body , dict ) else []
1255- now = time . time ()
1256- found : set [str ] = set ()
1260+
1261+ picklists : Dict [str , Dict [ str , int ]] = {}
12571262 for item in items :
12581263 if not isinstance (item , dict ):
12591264 continue
12601265 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 ):
1266+ if not ln :
13141267 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 {}
1268+ option_set = item .get ("OptionSet" ) or {}
1269+ options = option_set .get ("Options" ) if isinstance (option_set , dict ) else None
1270+ mapping : Dict [str , int ] = {}
1271+ if isinstance (options , list ):
1272+ for opt in options :
1273+ if not isinstance (opt , dict ):
1274+ continue
1275+ val = opt .get ("Value" )
1276+ if not isinstance (val , int ):
1277+ continue
1278+ label_def = opt .get ("Label" ) or {}
1279+ locs = label_def .get ("LocalizedLabels" )
1280+ if isinstance (locs , list ):
1281+ for loc in locs :
1282+ if isinstance (loc , dict ):
1283+ lab = loc .get ("Label" )
1284+ if isinstance (lab , str ) and lab .strip ():
1285+ normalized = self ._normalize_picklist_label (lab )
1286+ mapping .setdefault (normalized , val )
1287+ picklists [ln ] = mapping
1288+
1289+ self ._picklist_label_cache [table_key ] = {"ts" : now , "picklists" : picklists }
13331290
13341291 def _convert_labels_to_ints (self , table_schema_name : str , record : Dict [str , Any ]) -> Dict [str , Any ]:
13351292 """Return a copy of record with any labels converted to option ints.
13361293
13371294 Heuristic: For each string value, attempt to resolve against picklist metadata.
13381295 If attribute isn't a picklist or label not found, value left unchanged.
13391296
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 .
1297+ Performance (Option C): on first encounter of a table, bulk-fetches all
1298+ picklist attributes and their options in a single API call, then resolves
1299+ labels from the warm cache .
13431300 """
13441301 resolved_record = record .copy ()
1345- # Collect candidate string-valued attribute names
1346- candidates : List [str ] = []
1302+
1303+ # Check if there are any string-valued candidates worth resolving
1304+ has_candidates = any (
1305+ isinstance (v , str ) and v .strip () and isinstance (k , str ) and "@odata." not in k
1306+ for k , v in resolved_record .items ()
1307+ )
1308+ if not has_candidates :
1309+ return resolved_record
1310+
1311+ # Bulk-fetch all picklists for this table (1 API call, cached for TTL)
1312+ self ._bulk_fetch_picklists (table_schema_name )
1313+
1314+ # Resolve labels from the nested cache
1315+ table_key = self ._normalize_cache_key (table_schema_name )
1316+ table_entry = self ._picklist_label_cache .get (table_key )
1317+ if not isinstance (table_entry , dict ):
1318+ return resolved_record
1319+ picklists = table_entry .get ("picklists" , {})
1320+
13471321 for k , v in resolved_record .items ():
13481322 if not isinstance (v , str ) or not v .strip ():
13491323 continue
13501324 if isinstance (k , str ) and "@odata." in k :
13511325 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 :
1326+ attr_key = self ._normalize_cache_key (k )
1327+ mapping = picklists .get (attr_key )
1328+ if not isinstance (mapping , dict ) or not mapping :
13811329 continue
1382- norm = self ._normalize_picklist_label (resolved_record [ k ] )
1330+ norm = self ._normalize_picklist_label (v )
13831331 val = mapping .get (norm )
13841332 if val is not None :
13851333 resolved_record [k ] = val
0 commit comments