Skip to content

Commit 8cf08b4

Browse files
suyask-msftclaude
andcommitted
Harden @odata.bind handling: perf fix, warning, and skill docs
- Skip @OData. keys in _convert_labels_to_ints to avoid unnecessary metadata HTTP calls for every @odata.bind key in record payloads - Add runtime warning when @odata.bind keys use lowercase navigation property names (likely to cause 400 "undeclared property" errors) - Fix _get $select consistency: lowercase column names like _get_multiple - Add tests for warning behavior and _convert_labels_to_ints skip - Update dataverse-sdk-dev skill with property naming rules (structural vs navigation) to help contributors avoid casing bugs - Update dataverse-sdk-use skill with @odata.bind examples and troubleshooting guidance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dbea39f commit 8cf08b4

5 files changed

Lines changed: 135 additions & 4 deletions

File tree

.claude/skills/dataverse-sdk-dev/SKILL.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
2020
5. **Consider backwards compatibility** - Avoid breaking changes
2121
6. **Internal vs public naming** - Modules, files, and functions not meant to be part of the public API must use a `_` prefix (e.g., `_odata.py`, `_relationships.py`). Files without the prefix (e.g., `constants.py`, `metadata.py`) are public and importable by SDK consumers
2222

23+
### Dataverse Property Naming Rules
24+
25+
Dataverse uses two different naming conventions for properties. Getting this wrong causes 400 errors that are hard to debug.
26+
27+
| Property type | Name convention | Example | When used |
28+
|---|---|---|---|
29+
| **Structural** (columns) | LogicalName = always lowercase | `new_name`, `new_priority` | `$select`, `$filter`, `$orderby`, record payload keys |
30+
| **Navigation** (lookups) | SchemaName = PascalCase | `new_CustomerId`, `new_AgentId` | `$expand`, `@odata.bind` annotation keys |
31+
32+
**Critical rule:** The OData parser validates `@odata.bind` property names **case-sensitively** against declared navigation properties. Lowercasing `new_CustomerId@odata.bind` to `new_customerid@odata.bind` causes: `ODataException: An undeclared property 'new_customerid' which only has property annotations...`
33+
34+
**SDK implementation:**
35+
- `_lowercase_keys()` lowercases all keys EXCEPT those containing `@odata.` -- this preserves navigation property casing in `@odata.bind` keys
36+
- `_lowercase_list()` lowercases `$select`/`$orderby` params (structural properties)
37+
- `$expand` params are passed as-is (navigation properties, PascalCase)
38+
- `_convert_labels_to_ints()` skips `@odata.` keys entirely (they are annotations, not attributes)
39+
40+
**When adding new code that processes record dicts or builds query parameters:**
41+
- Always use `_lowercase_keys()` for record payloads -- never manually call `.lower()` on all keys
42+
- Never lowercase `$expand` values or `@odata.bind` key prefixes
43+
- If iterating record keys, skip keys containing `@odata.` when doing attribute-level operations
44+
2345
### Code Style
2446

2547
6. **No emojis** - Do not use emoji in code, comments, or output

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ for page in client.records.get(
105105
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
106106
```
107107

108+
#### Create Records with Lookup Bindings (@odata.bind)
109+
```python
110+
# Set lookup fields using @odata.bind with PascalCase navigation property names
111+
# CORRECT: PascalCase SchemaName before @odata.bind
112+
guid = client.records.create("new_ticket", {
113+
"new_name": "TKT-001",
114+
"new_CustomerId@odata.bind": f"/new_customers({customer_id})",
115+
"new_AgentId@odata.bind": f"/new_agents({agent_id})",
116+
})
117+
118+
# WRONG: lowercase navigation property causes 400 error
119+
# "new_customerid@odata.bind" -> ODataException: undeclared property 'new_customerid'
120+
```
121+
108122
#### Update Records
109123
```python
110124
# Single update
@@ -359,6 +373,7 @@ except ValidationError as e:
359373
- Check filter/expand parameters use correct case
360374
- Verify column names exist and are spelled correctly
361375
- Ensure custom columns include customization prefix
376+
- For `@odata.bind` errors ("undeclared property"): the navigation property name before `@odata.bind` must use **PascalCase SchemaName** (e.g., `new_CustomerId@odata.bind`), not lowercase. The OData parser is case-sensitive for navigation property names. The SDK preserves `@odata.bind` key casing and emits a warning if it detects likely-wrong lowercase casing.
362377

363378
## Best Practices
364379

@@ -371,7 +386,7 @@ except ValidationError as e:
371386
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372387
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373388
7. **Always include customization prefix** for custom tables/columns
374-
8. **Use lowercase** - Generally using lowercase input won't go wrong, except for custom table/column naming
389+
8. **Use lowercase for column names, PascalCase for navigation properties** - Column names in `$select`/`$filter`/record payloads use lowercase LogicalNames. Navigation properties in `$expand` and `@odata.bind` keys use PascalCase SchemaName (e.g., `new_CustomerId@odata.bind`)
375390
9. **Test in non-production environments** first
376391
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
377392

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ for page in client.records.get(
105105
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
106106
```
107107

108+
#### Create Records with Lookup Bindings (@odata.bind)
109+
```python
110+
# Set lookup fields using @odata.bind with PascalCase navigation property names
111+
# CORRECT: PascalCase SchemaName before @odata.bind
112+
guid = client.records.create("new_ticket", {
113+
"new_name": "TKT-001",
114+
"new_CustomerId@odata.bind": f"/new_customers({customer_id})",
115+
"new_AgentId@odata.bind": f"/new_agents({agent_id})",
116+
})
117+
118+
# WRONG: lowercase navigation property causes 400 error
119+
# "new_customerid@odata.bind" -> ODataException: undeclared property 'new_customerid'
120+
```
121+
108122
#### Update Records
109123
```python
110124
# Single update
@@ -359,6 +373,7 @@ except ValidationError as e:
359373
- Check filter/expand parameters use correct case
360374
- Verify column names exist and are spelled correctly
361375
- Ensure custom columns include customization prefix
376+
- For `@odata.bind` errors ("undeclared property"): the navigation property name before `@odata.bind` must use **PascalCase SchemaName** (e.g., `new_CustomerId@odata.bind`), not lowercase. The OData parser is case-sensitive for navigation property names. The SDK preserves `@odata.bind` key casing and emits a warning if it detects likely-wrong lowercase casing.
362377

363378
## Best Practices
364379

@@ -371,7 +386,7 @@ except ValidationError as e:
371386
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372387
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373388
7. **Always include customization prefix** for custom tables/columns
374-
8. **Use lowercase** - Generally using lowercase input won't go wrong, except for custom table/column naming
389+
8. **Use lowercase for column names, PascalCase for navigation properties** - Column names in `$select`/`$filter`/record payloads use lowercase LogicalNames. Navigation properties in `$expand` and `@odata.bind` keys use PascalCase SchemaName (e.g., `new_CustomerId@odata.bind`)
375390
9. **Test in non-production environments** first
376391
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
377392

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import re
1414
import json
1515
import uuid
16+
import warnings
1617
from datetime import datetime, timezone
1718
import importlib.resources as ir
1819
from contextlib import contextmanager
@@ -99,10 +100,28 @@ def _lowercase_keys(record: Dict[str, Any]) -> Dict[str, Any]:
99100
100101
Keys containing ``@odata.`` (e.g. ``new_CustomerId@odata.bind``) are
101102
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.
103107
"""
104108
if not isinstance(record, dict):
105109
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+
)
106125
return {k.lower() if isinstance(k, str) and "@odata." not in k else k: v for k, v in record.items()}
107126

108127
@staticmethod
@@ -724,7 +743,7 @@ def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = N
724743
params = {}
725744
if select:
726745
# Lowercase column names for case-insensitive matching
727-
params["$select"] = ",".join(select)
746+
params["$select"] = ",".join(self._lowercase_list(select))
728747
entity_set = self._entity_set_from_schema_name(table_schema_name)
729748
url = f"{self.api}/{entity_set}{self._format_key(key)}"
730749
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]
13241343
for k, v in list(out.items()):
13251344
if not isinstance(v, str) or not v.strip():
13261345
continue
1346+
# Skip OData annotations — they are not attribute names
1347+
if isinstance(k, str) and "@odata." in k:
1348+
continue
13271349
mapping = self._optionset_map(table_schema_name, k)
13281350
if not mapping:
13291351
continue

tests/unit/data/test_odata_internal.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,63 @@ def test_odata_bind_keys_preserve_case(self):
354354
self.assertIn("new_CustomerId@odata.bind", payload)
355355
self.assertNotIn("new_customerid@odata.bind", payload)
356356

357+
def test_odata_bind_lowercase_warns(self):
358+
"""Lowercase @odata.bind nav property emits a warning."""
359+
import warnings
360+
361+
with warnings.catch_warnings(record=True) as w:
362+
warnings.simplefilter("always")
363+
self.od._upsert(
364+
"accounts",
365+
"account",
366+
{"accountnumber": "ACC-001"},
367+
{
368+
"name": "Contoso",
369+
"new_customerid@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)",
370+
},
371+
)
372+
odata_warnings = [x for x in w if "@odata.bind" in str(x.message)]
373+
self.assertEqual(len(odata_warnings), 1)
374+
self.assertIn("PascalCase", str(odata_warnings[0].message))
375+
376+
def test_odata_bind_pascalcase_no_warning(self):
377+
"""PascalCase @odata.bind nav property does NOT emit a warning."""
378+
import warnings
379+
380+
with warnings.catch_warnings(record=True) as w:
381+
warnings.simplefilter("always")
382+
self.od._upsert(
383+
"accounts",
384+
"account",
385+
{"accountnumber": "ACC-001"},
386+
{
387+
"name": "Contoso",
388+
"new_CustomerId@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)",
389+
},
390+
)
391+
odata_warnings = [x for x in w if "@odata.bind" in str(x.message)]
392+
self.assertEqual(len(odata_warnings), 0)
393+
394+
def test_convert_labels_skips_odata_keys(self):
395+
"""_convert_labels_to_ints should skip @odata.bind keys (no metadata lookup)."""
396+
# Patch _optionset_map to track calls
397+
calls = []
398+
original = self.od._optionset_map
399+
400+
def tracking_optionset_map(table, attr):
401+
calls.append(attr)
402+
return original(table, attr)
403+
404+
self.od._optionset_map = tracking_optionset_map
405+
record = {
406+
"name": "Contoso",
407+
"new_CustomerId@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)",
408+
"@odata.type": "Microsoft.Dynamics.CRM.account",
409+
}
410+
self.od._convert_labels_to_ints("account", record)
411+
# Only "name" should be checked, not the @odata keys
412+
self.assertEqual(calls, ["name"])
413+
357414
def test_returns_none(self):
358415
"""_upsert always returns None."""
359416
result = self.od._upsert("accounts", "account", {"accountnumber": "ACC-001"}, {"name": "Contoso"})

0 commit comments

Comments
 (0)