Skip to content

Commit c91d6c9

Browse files
author
Abel Milash
committed
Option C: bulk fetch all picklists in single API call
1 parent 203af1e commit c91d6c9

4 files changed

Lines changed: 480 additions & 596 deletions

File tree

examples/advanced/walkthrough.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,16 @@ def _run_walkthrough(client):
444444
print(f" new_Priority stored as integer: {retrieved.get('new_priority')}")
445445
print(f" new_Priority@FormattedValue: {retrieved.get('new_priority@OData.Community.Display.V1.FormattedValue')}")
446446

447+
# Print the cached picklist metadata for visibility
448+
odata = client._get_odata()
449+
table_key = table_name.lower()
450+
cache_entry = odata._picklist_label_cache.get(table_key, {})
451+
print(f"\n [DEBUG] Picklist cache for '{table_key}':")
452+
picklists = cache_entry.get("picklists", {})
453+
print(f" Cached {len(picklists)} picklist attribute(s):")
454+
for attr, mapping in picklists.items():
455+
print(f" {attr}: {json.dumps(mapping, indent=6)}")
456+
447457
# Update with a string label
448458
log_call(f"client.records.update('{table_name}', label_id, {{'new_Priority': 'Low'}})")
449459
backoff(lambda: client.records.update(table_name, label_id, {"new_Priority": "Low"}))

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 71 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)