Skip to content

Commit ba3352a

Browse files
Samson Gebreclaude
andcommitted
Merge origin/main into users/sagebree/issue157_fix_sql_truncation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 63bdd67 + 78cd852 commit ba3352a

61 files changed

Lines changed: 13507 additions & 615 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ 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** (relationships / lookups) | Navigation Property Name (usually SchemaName, PascalCase, case-sensitive) | `new_CustomerId`, `new_AgentId` | `$expand`, `@odata.bind` annotation keys |
31+
32+
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+
2349
### Code Style
2450

2551
6. **No emojis** - Do not use emoji in code, comments, or output
@@ -28,3 +54,35 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
2854
9. **Document public APIs** - Add Sphinx-style docstrings with examples for public methods
2955
10. **Define __all__ in module files** - Each module declares its own exports via `__all__` (e.g., `errors.py` defines `__all__ = ["HttpError", ...]`). Package `__init__.py` files should not re-export or redefine another module's `__all__`; they use `__all__ = []` to indicate no star-import exports.
3056
11. **Run black before committing** - Always run `python -m black <changed files>` before committing. CI will reject unformatted code. Config is in `pyproject.toml` under `[tool.black]`.
57+
58+
### Docstring Type Annotations (Microsoft Learn Compatibility)
59+
60+
This SDK's API reference is published on Microsoft Learn. The Learn doc pipeline parses `:type:` and `:rtype:` directives differently from standard Sphinx -- every word between `:class:` references is treated as a separate cross-reference (`<xref:word>`). Using Sphinx-style `:class:\`list\` of :class:\`str\`` produces broken `<xref:of>` links on Learn.
61+
62+
**Rules for `:type:` and `:rtype:` directives:**
63+
64+
- Use Python bracket notation for generic types: `list[str]`, `dict[str, typing.Any]`, `list[dict]`
65+
- Use `or` (without `:class:`) for union types: `str or None`, `dict or list[dict]`
66+
- Use bracket nesting for complex types: `collections.abc.Iterable[list[dict]]`
67+
- Use `~` prefix for SDK types to show short name: `list[~PowerPlatform.Dataverse.models.record.Record]`
68+
- `:class:` is fine for single standalone types: `:class:\`str\``, `:class:\`bool\``
69+
70+
**Never** use `:class:\`X\` of :class:\`Y\`` or `:class:\`X\` mapping :class:\`Y\` to :class:\`Z\`` -- the words `of`, `mapping`, `to` become broken `<xref:>` links.
71+
72+
**Correct examples:**
73+
74+
```rst
75+
:type data: dict or list[dict]
76+
:rtype: list[str]
77+
:rtype: collections.abc.Iterable[list[~PowerPlatform.Dataverse.models.record.Record]]
78+
:type select: list[str] or None
79+
:type columns: dict[str, typing.Any]
80+
```
81+
82+
**Wrong examples (NEVER use):**
83+
84+
```rst
85+
:type data: :class:`dict` or :class:`list` of :class:`dict`
86+
:rtype: :class:`list` of :class:`str`
87+
:type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any`
88+
```

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `
3131
- Control page size with `page_size` parameter
3232
- Use `top` parameter to limit total records returned
3333

34+
### DataFrame Support
35+
- DataFrame operations are accessed via the `client.dataframe` namespace: `client.dataframe.get()`, `client.dataframe.create()`, `client.dataframe.update()`, `client.dataframe.delete()`
36+
3437
## Common Operations
3538

3639
### Import
@@ -54,7 +57,13 @@ credential = AzureCliCredential()
5457
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
5558
credential = CertificateCredential(tenant_id, client_id, cert_path)
5659

57-
# Create client (no trailing slash on URL!)
60+
# Create client with context manager (recommended -- enables HTTP connection pooling)
61+
# No trailing slash on URL!
62+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
63+
... # all operations here
64+
# Session closed, caches cleared automatically
65+
66+
# Or without context manager:
5867
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
5968
```
6069

@@ -100,6 +109,20 @@ for page in client.records.get(
100109
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
101110
```
102111

112+
#### Create Records with Lookup Bindings (@odata.bind)
113+
```python
114+
# Set lookup fields using @odata.bind with PascalCase navigation property names
115+
# CORRECT: use the navigation property name (case-sensitive, must match $metadata)
116+
guid = client.records.create("new_ticket", {
117+
"new_name": "TKT-001",
118+
"new_CustomerId@odata.bind": f"/new_customers({customer_id})",
119+
"new_AgentId@odata.bind": f"/new_agents({agent_id})",
120+
})
121+
122+
# WRONG: lowercase navigation property causes 400 error
123+
# "new_customerid@odata.bind" -> ODataException: undeclared property 'new_customerid'
124+
```
125+
103126
#### Update Records
104127
```python
105128
# Single update
@@ -110,7 +133,7 @@ client.records.update("account", [id1, id2, id3], {"industry": "Technology"})
110133
```
111134

112135
#### Upsert Records
113-
Creates or updates records identified by alternate keys. Single item PATCH; multiple items `UpsertMultiple` bulk action.
136+
Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action.
114137
> **Prerequisite**: The table must have an alternate key configured in Dataverse for the columns used in `alternate_key`. Without it, Dataverse will reject the request with a 400 error.
115138
```python
116139
from PowerPlatform.Dataverse.models.upsert import UpsertItem
@@ -152,6 +175,42 @@ client.records.delete("account", account_id)
152175
client.records.delete("account", [id1, id2, id3], use_bulk_delete=True)
153176
```
154177

178+
### DataFrame Operations
179+
180+
The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output.
181+
182+
```python
183+
import pandas as pd
184+
185+
# Query records -- returns a single DataFrame
186+
df = client.dataframe.get("account", filter="statecode eq 0", select=["name"])
187+
print(f"Got {len(df)} rows")
188+
189+
# Limit results with top for large tables
190+
df = client.dataframe.get("account", select=["name"], top=100)
191+
192+
# Fetch single record as one-row DataFrame
193+
df = client.dataframe.get("account", record_id=account_id, select=["name"])
194+
195+
# Create records from a DataFrame (returns a Series of GUIDs)
196+
new_accounts = pd.DataFrame([
197+
{"name": "Contoso", "telephone1": "555-0100"},
198+
{"name": "Fabrikam", "telephone1": "555-0200"},
199+
])
200+
new_accounts["accountid"] = client.dataframe.create("account", new_accounts)
201+
202+
# Update records from a DataFrame (id_column identifies the GUID column)
203+
new_accounts["telephone1"] = ["555-0199", "555-0299"]
204+
client.dataframe.update("account", new_accounts, id_column="accountid")
205+
206+
# Clear a field by setting clear_nulls=True (by default, NaN/None fields are skipped)
207+
df = pd.DataFrame([{"accountid": "guid-1", "websiteurl": None}])
208+
client.dataframe.update("account", df, id_column="accountid", clear_nulls=True)
209+
210+
# Delete records by passing a Series of GUIDs
211+
client.dataframe.delete("account", new_accounts["accountid"])
212+
```
213+
155214
### SQL Queries
156215

157216
SQL queries are **read-only** and support limited SQL syntax. A single SELECT statement with optional WHERE, TOP (integer literal), ORDER BY (column names only), and a simple table alias after FROM is supported. But JOIN and subqueries may not be. Refer to the Dataverse documentation for the current feature set.
@@ -348,7 +407,7 @@ print(f"Succeeded: {len(result.succeeded)}, Failed: {len(result.failed)}")
348407
- `result.succeeded` -- responses with 2xx status codes
349408
- `result.failed` -- responses with non-2xx status codes
350409
- `result.has_errors` -- True if any response failed
351-
- `result.created_ids` -- GUIDs from successful create operations
410+
- `result.entity_ids` -- GUIDs from OData-EntityId headers (creates and updates)
352411

353412
**Batch limitations:**
354413
- Maximum 1000 operations per batch
@@ -398,6 +457,7 @@ except ValidationError as e:
398457
- Check filter/expand parameters use correct case
399458
- Verify column names exist and are spelled correctly
400459
- Ensure custom columns include customization prefix
460+
- 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.
401461

402462
## Best Practices
403463

@@ -410,7 +470,7 @@ except ValidationError as e:
410470
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
411471
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
412472
7. **Always include customization prefix** for custom tables/columns
413-
8. **Use lowercase** - Generally using lowercase input won't go wrong, except for custom table/column naming
473+
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`)
414474
9. **Test in non-production environments** first
415475
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
416476

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ Thumbs.db
2525

2626
# Claude local settings
2727
.claude/*.local.json
28+
.claude/*.local.md

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111
- `client.query.sql()` silently truncated results at 5,000 rows. The method now follows `@odata.nextLink` pagination and returns all matching rows (#157).
1212

13+
## [0.1.0b7] - 2026-03-17
14+
15+
### Added
16+
- DataFrame namespace: `client.dataframe.get()`, `.create()`, `.update()`, `.delete()` for working with Dataverse records as pandas DataFrames and Series — no manual dict conversion required (#98)
17+
- Table metadata now includes `primary_name_attribute` and `primary_id_attribute` from `tables.create()` and `tables.get_info()` (#148)
18+
19+
### Changed
20+
- `pandas>=2.0.0` is now a required dependency (#98)
21+
22+
## [0.1.0b6] - 2026-03-12
23+
24+
### Added
25+
- Context manager support: `with DataverseClient(...) as client:` for automatic resource cleanup, HTTP connection pooling, and `close()` for explicit lifecycle management (#117)
26+
- Typed return models `Record`, `TableInfo`, and `ColumnInfo` for record and table metadata operations, replacing raw `Dict[str, Any]` returns with full backward compatibility (`result["key"]` still works) (#115)
27+
- Alternate key management: `client.tables.create_alternate_key()`, `client.tables.get_alternate_keys()`, `client.tables.delete_alternate_key()` with typed `AlternateKeyInfo` model (#126)
28+
29+
### Fixed
30+
- `@odata.bind` lookup bindings now preserve navigation property casing (e.g., `new_CustomerId@odata.bind`), fixing `400 Bad Request` errors on create/update/upsert with lookup fields (#137)
31+
- Reduced unnecessary HTTP round-trips on create/update/upsert when records contain `@odata.bind` keys (#137)
32+
- Single-record `get()` now lowercases `$select` column names consistently with multi-record queries (#137)
33+
1334
## [0.1.0b5] - 2026-02-27
1435

1536
### Fixed
@@ -75,6 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7596
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
7697
- HTTP retry logic with exponential backoff for resilient operations (#72)
7798

99+
[0.1.0b7]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b6...v0.1.0b7
100+
[0.1.0b6]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b5...v0.1.0b6
78101
[0.1.0b5]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b4...v0.1.0b5
79102
[0.1.0b4]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b3...v0.1.0b4
80103
[0.1.0b3]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b2...v0.1.0b3

CONTRIBUTING.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,43 @@ published release:
119119

120120
```bash
121121
# After publishing v0.1.0b4, bump to v0.1.0b5 on main
122-
# Update both pyproject.toml and src/PowerPlatform/Dataverse/__version__.py
122+
# Update version in pyproject.toml
123123
# Commit directly to main: "Bump version to 0.1.0b5 for next development cycle"
124+
```
125+
126+
### Docstring Type Annotations (Microsoft Learn Compatibility)
127+
128+
This SDK's API reference is published on [Microsoft Learn](https://learn.microsoft.com). The Learn doc pipeline processes `:type:` and `:rtype:` Sphinx directives differently from standard Sphinx -- every word between `:class:` back-tick references is treated as a separate cross-reference (`<xref:word>`). For example:
129+
130+
```
131+
:rtype: :class:`list` of :class:`str`
132+
```
133+
134+
This produces a broken `<xref:of>` link because `of` is not a valid type.
135+
136+
**Rules for `:type:` and `:rtype:` directives:**
137+
138+
- Use **Python bracket notation** for generic types: `list[str]`, `dict[str, typing.Any]`, `list[dict]`
139+
- Use **`or`** (without `:class:`) for union types: `str or None`, `dict or list[dict]`
140+
- Use **bracket nesting** for complex types: `collections.abc.Iterable[list[dict]]`
141+
- `:class:` is fine for **single standalone types**: `` :class:`str` ``, `` :class:`bool` ``
142+
143+
**NEVER** use the following patterns -- the connector words (`of`, `mapping`, `to`) become broken `<xref:>` links on Learn:
144+
145+
```
146+
:class:`X` of :class:`Y`
147+
:class:`X` mapping :class:`Y` to :class:`Z`
148+
```
149+
150+
Correct:
151+
```
152+
:type data: dict or list[dict]
153+
:rtype: list[str]
154+
:type select: list[str] or None
155+
```
156+
157+
Wrong:
158+
```
159+
:type data: :class:`dict` or :class:`list` of :class:`dict`
160+
:rtype: :class:`list` of :class:`str`
124161
```

0 commit comments

Comments
 (0)