You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Fix @odata.bind key casing and harden OData annotation handling (#137)
# Fix @odata.bind key casing and harden OData annotation handling
## Summary
The SDK's `_lowercase_keys()` was unconditionally lowercasing all
dictionary keys in record payloads, including `@odata.bind` annotation
keys like `new_CustomerId@odata.bind`. This broke lookup field bindings
because the Dataverse OData parser validates navigation property names
**case-sensitively**.
**Root cause:** Dataverse uses two naming conventions:
- **Structural properties** (columns): LogicalName, always lowercase
(`new_name`, `new_priority`)
- **Navigation properties** (lookups): SchemaName, PascalCase
(`new_CustomerId`, `new_AgentId`)
The OData parser (`Microsoft.OData.Core`) rejects lowercased navigation
property names with: `ODataException: An undeclared property
'new_customerid' which only has property annotations in the payload but
no property value was found in the payload.`
Note: CDS's internal RelationshipService *is* case-insensitive, but it
never runs because the OData parser rejects the payload first.
## Changes
### Bug fixes
- **Preserve `@odata.bind` key casing** -- `_lowercase_keys()` now skips
keys containing `@odata.`, preserving the PascalCase navigation property
name that Dataverse requires
- **Skip `@odata.` keys in `_convert_labels_to_ints()`** -- Previously
made unnecessary HTTP metadata API calls for every `@odata.bind` key
(checking if it's a picklist attribute). These always returned empty
results but wasted an HTTP round-trip per annotation key per record on
every create/update/upsert
- **Fix `_get` `$select` consistency** -- Single-record `_get()` now
lowercases `$select` column names via `_lowercase_list()`, matching the
behavior of `_get_multiple()`
### Developer guardrails
- **Runtime warning for likely-wrong casing** -- `_lowercase_keys()` now
emits a `warnings.warn()` when it detects an `@odata.bind` key where the
navigation property portion is all-lowercase (e.g.,
`new_customerid@odata.bind`), alerting developers before they hit a
cryptic 400 error
### Tests
- `test_odata_bind_keys_preserve_case` -- PascalCase `@odata.bind` keys
are preserved through the write path
- `test_odata_bind_lowercase_warns` -- Lowercase nav property in
`@odata.bind` triggers a warning
- `test_odata_bind_pascalcase_no_warning` -- Correct PascalCase does not
trigger false positive
- `test_convert_labels_skips_odata_keys` -- Verifies
`_convert_labels_to_ints` does not call `_optionset_map` for `@odata.`
keys
### Documentation
- **`dataverse-sdk-dev` skill** -- Added "Dataverse Property Naming
Rules" section explaining structural vs navigation property conventions
and implementation rules for contributors
- **`dataverse-sdk-use` skill** -- Added `@odata.bind` usage examples,
400 error troubleshooting guidance, and corrected best practice on
casing
## Before / After
**Before:** SDK sent `{"new_customerid@odata.bind": ...}` -- 400 error
**After:** SDK sends `{"new_CustomerId@odata.bind": ...}` -- success
```python
# User code (unchanged -- SDK now preserves their casing correctly)
client.records.create("new_ticket", {
"new_name": "TKT-001",
"new_CustomerId@odata.bind": "/new_customers(guid)",
})
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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
22
22
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 |
Navigation property names are case-sensitive and must match the entity's `$metadata`. Using the logical name instead of the navigation property name results in 400 Bad Request errors.
33
+
34
+
**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...`
35
+
36
+
**SDK implementation:**
37
+
38
+
-`_lowercase_keys()` lowercases all keys EXCEPT those containing `@odata.` (preserves navigation property casing in `@odata.bind` keys)
39
+
-`_lowercase_list()` lowercases `$select` and `$orderby` params (structural properties)
40
+
-`$expand` params are passed as-is (navigation properties, PascalCase)
41
+
-`_convert_labels_to_ints()` skips `@odata.` keys entirely (they are annotations, not attributes)
42
+
43
+
**When adding new code that processes record dicts or builds query parameters:**
44
+
45
+
- Always use `_lowercase_keys()` for record payloads. Never manually call `.lower()` on all keys
46
+
- Never lowercase `$expand` values or `@odata.bind` key prefixes
47
+
- If iterating record keys, skip keys containing `@odata.` when doing attribute-level operations
48
+
23
49
### Code Style
24
50
25
51
6.**No emojis** - Do not use emoji in code, comments, or output
- Verify column names exist and are spelled correctly
361
375
- Ensure custom columns include customization prefix
376
+
- For `@odata.bind` errors ("undeclared property"): the navigation property name before `@odata.bind` is case-sensitive and must match the entity's `$metadata` exactly (e.g., `new_CustomerId@odata.bind` for custom lookups, `parentaccountid@odata.bind` for system lookups). The SDK preserves `@odata.bind` key casing.
362
377
363
378
## Best Practices
364
379
@@ -371,7 +386,7 @@ except ValidationError as e:
371
386
5.**Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372
387
6.**Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373
388
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, match `$metadata` for navigation properties** - Column names in `$select`/`$filter`/record payloads use lowercase LogicalNames. Navigation properties in `$expand` and `@odata.bind` keys are case-sensitive and must match the entity's `$metadata` (PascalCase for custom lookups like `new_CustomerId`, lowercase for system lookups like `parentaccountid`)
375
390
9.**Test in non-production environments** first
376
391
10.**Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
- Verify column names exist and are spelled correctly
361
375
- Ensure custom columns include customization prefix
376
+
- For `@odata.bind` errors ("undeclared property"): the navigation property name before `@odata.bind` is case-sensitive and must match the entity's `$metadata` exactly (e.g., `new_CustomerId@odata.bind` for custom lookups, `parentaccountid@odata.bind` for system lookups). The SDK preserves `@odata.bind` key casing.
362
377
363
378
## Best Practices
364
379
@@ -371,7 +386,7 @@ except ValidationError as e:
371
386
5.**Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372
387
6.**Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373
388
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, match `$metadata` for navigation properties** - Column names in `$select`/`$filter`/record payloads use lowercase LogicalNames. Navigation properties in `$expand` and `@odata.bind` keys are case-sensitive and must match the entity's `$metadata` (PascalCase for custom lookups like `new_CustomerId`, lowercase for system lookups like `parentaccountid`)
375
390
9.**Test in non-production environments** first
376
391
10.**Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
0 commit comments