|
13 | 13 | import re |
14 | 14 | import json |
15 | 15 | import uuid |
| 16 | +import warnings |
16 | 17 | from datetime import datetime, timezone |
17 | 18 | import importlib.resources as ir |
18 | 19 | from contextlib import contextmanager |
@@ -99,10 +100,28 @@ def _lowercase_keys(record: Dict[str, Any]) -> Dict[str, Any]: |
99 | 100 |
|
100 | 101 | Keys containing ``@odata.`` (e.g. ``new_CustomerId@odata.bind``) are |
101 | 102 | preserved as-is because the navigation property portion before ``@`` |
102 | | - must retain its original casing (PascalCase SchemaName). |
| 103 | + must retain its original casing (PascalCase SchemaName). The OData |
| 104 | + parser validates ``@odata.bind`` property names **case-sensitively** |
| 105 | + against the entity's declared navigation properties, so lowercasing |
| 106 | + these keys causes ``400 - undeclared property`` errors. |
103 | 107 | """ |
104 | 108 | if not isinstance(record, dict): |
105 | 109 | return record |
| 110 | + for k in record: |
| 111 | + if isinstance(k, str) and "@odata.bind" in k: |
| 112 | + nav_prop = k.split("@odata.bind")[0] |
| 113 | + if nav_prop and nav_prop == nav_prop.lower() and "_" in nav_prop: |
| 114 | + # Likely already-lowercased navigation property name. |
| 115 | + # Navigation properties use PascalCase SchemaName (e.g. |
| 116 | + # new_CustomerId), not lowercase LogicalName. |
| 117 | + warnings.warn( |
| 118 | + f"@odata.bind key '{k}' appears to use a lowercase " |
| 119 | + f"navigation property name. Dataverse requires " |
| 120 | + f"PascalCase SchemaName (e.g. 'new_CustomerId@odata.bind'," |
| 121 | + f" not 'new_customerid@odata.bind'). This will likely " |
| 122 | + f"cause a 400 error.", |
| 123 | + stacklevel=4, |
| 124 | + ) |
106 | 125 | return {k.lower() if isinstance(k, str) and "@odata." not in k else k: v for k, v in record.items()} |
107 | 126 |
|
108 | 127 | @staticmethod |
@@ -724,7 +743,7 @@ def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = N |
724 | 743 | params = {} |
725 | 744 | if select: |
726 | 745 | # Lowercase column names for case-insensitive matching |
727 | | - params["$select"] = ",".join(select) |
| 746 | + params["$select"] = ",".join(self._lowercase_list(select)) |
728 | 747 | entity_set = self._entity_set_from_schema_name(table_schema_name) |
729 | 748 | url = f"{self.api}/{entity_set}{self._format_key(key)}" |
730 | 749 | r = self._request("get", url, params=params) |
@@ -1324,6 +1343,9 @@ def _convert_labels_to_ints(self, table_schema_name: str, record: Dict[str, Any] |
1324 | 1343 | for k, v in list(out.items()): |
1325 | 1344 | if not isinstance(v, str) or not v.strip(): |
1326 | 1345 | continue |
| 1346 | + # Skip OData annotations — they are not attribute names |
| 1347 | + if isinstance(k, str) and "@odata." in k: |
| 1348 | + continue |
1327 | 1349 | mapping = self._optionset_map(table_schema_name, k) |
1328 | 1350 | if not mapping: |
1329 | 1351 | continue |
|
0 commit comments