Skip to content

Commit 9f65211

Browse files
author
Abel Milash
committed
Merge origin/feature/querybuilder-clean and add expand examples to walkthrough
2 parents dde2536 + b08639d commit 9f65211

29 files changed

Lines changed: 4151 additions & 40 deletions

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

Lines changed: 26 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

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

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

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

3538
### Import
@@ -105,6 +108,20 @@ for page in client.records.get(
105108
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
106109
```
107110

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

117134
#### Upsert Records
118-
Creates or updates records identified by alternate keys. Single item PATCH; multiple items `UpsertMultiple` bulk action.
135+
Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action.
119136
> **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.
120137
```python
121138
from PowerPlatform.Dataverse.models.upsert import UpsertItem
@@ -157,6 +174,42 @@ client.records.delete("account", account_id)
157174
client.records.delete("account", [id1, id2, id3], use_bulk_delete=True)
158175
```
159176

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

162215
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.
@@ -359,6 +412,7 @@ except ValidationError as e:
359412
- Check filter/expand parameters use correct case
360413
- Verify column names exist and are spelled correctly
361414
- Ensure custom columns include customization prefix
415+
- 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.
362416

363417
## Best Practices
364418

@@ -371,7 +425,7 @@ except ValidationError as e:
371425
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372426
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373427
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
428+
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`)
375429
9. **Test in non-production environments** first
376430
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
377431

.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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.0b6] - 2026-03-12
9+
10+
### Added
11+
- Context manager support: `with DataverseClient(...) as client:` for automatic resource cleanup, HTTP connection pooling, and `close()` for explicit lifecycle management (#117)
12+
- 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)
13+
- Alternate key management: `client.tables.create_alternate_key()`, `client.tables.get_alternate_keys()`, `client.tables.delete_alternate_key()` with typed `AlternateKeyInfo` model (#126)
14+
15+
### Fixed
16+
- `@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)
17+
- Reduced unnecessary HTTP round-trips on create/update/upsert when records contain `@odata.bind` keys (#137)
18+
- Single-record `get()` now lowercases `$select` column names consistently with multi-record queries (#137)
19+
820
## [0.1.0b5] - 2026-02-27
921

1022
### Fixed
@@ -70,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7082
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
7183
- HTTP retry logic with exponential backoff for resilient operations (#72)
7284

85+
[0.1.0b6]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b5...v0.1.0b6
7386
[0.1.0b5]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b4...v0.1.0b5
7487
[0.1.0b4]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b3...v0.1.0b4
7588
[0.1.0b3]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b2...v0.1.0b3

README.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
2424
- [Basic CRUD operations](#basic-crud-operations)
2525
- [Bulk operations](#bulk-operations)
2626
- [Upsert operations](#upsert-operations)
27+
- [DataFrame operations](#dataframe-operations)
2728
- [Query data](#query-data) *(QueryBuilder, SQL, raw OData)*
2829
- [Table management](#table-management)
2930
- [Relationship management](#relationship-management)
@@ -40,6 +41,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
4041
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
4142
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
4243
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
44+
- **🐼 DataFrame Support**: Pandas wrappers for all CRUD operations, returning DataFrames and Series
4345
- **📎 File Operations**: Upload files to Dataverse file columns with automatic chunking for large files
4446
- **🔐 Azure Identity**: Built-in authentication using Azure Identity credential providers with comprehensive support
4547
- **🛡️ Error Handling**: Structured exception hierarchy with detailed error context and retry guidance
@@ -233,6 +235,42 @@ client.records.upsert("account", [
233235
])
234236
```
235237

238+
### DataFrame operations
239+
240+
The SDK provides pandas wrappers for all CRUD operations via the `client.dataframe` namespace, using DataFrames and Series for input and output.
241+
242+
```python
243+
import pandas as pd
244+
245+
# Query records as a single DataFrame
246+
df = client.dataframe.get("account", filter="statecode eq 0", select=["name", "telephone1"])
247+
print(f"Found {len(df)} accounts")
248+
249+
# Limit results with top for large tables
250+
df = client.dataframe.get("account", select=["name"], top=100)
251+
252+
# Fetch a single record as a one-row DataFrame
253+
df = client.dataframe.get("account", record_id=account_id, select=["name"])
254+
255+
# Create records from a DataFrame (returns a Series of GUIDs)
256+
new_accounts = pd.DataFrame([
257+
{"name": "Contoso", "telephone1": "555-0100"},
258+
{"name": "Fabrikam", "telephone1": "555-0200"},
259+
])
260+
new_accounts["accountid"] = client.dataframe.create("account", new_accounts)
261+
262+
# Update records from a DataFrame (id_column identifies the GUID column)
263+
new_accounts["telephone1"] = ["555-0199", "555-0299"]
264+
client.dataframe.update("account", new_accounts, id_column="accountid")
265+
266+
# Clear a field by setting clear_nulls=True (by default, NaN/None fields are skipped)
267+
df = pd.DataFrame([{"accountid": new_accounts["accountid"].iloc[0], "websiteurl": None}])
268+
client.dataframe.update("account", df, id_column="accountid", clear_nulls=True)
269+
270+
# Delete records by passing a Series of GUIDs
271+
client.dataframe.delete("account", new_accounts["accountid"])
272+
```
273+
236274
### Query data
237275

238276
The **QueryBuilder** is the recommended way to query records. It provides a fluent, type-safe interface that generates correct OData queries automatically — no need to remember OData filter syntax.
@@ -252,6 +290,107 @@ for record in (client.query.builder("account")
252290

253291
The QueryBuilder handles value formatting, column name casing, and OData syntax automatically. All filter methods are discoverable via IDE autocomplete:
254292

293+
```python
294+
# Get results as a pandas DataFrame (consolidates all pages)
295+
df = (client.query.builder("account")
296+
.select("name", "telephone1")
297+
.filter_eq("statecode", 0)
298+
.top(100)
299+
.to_dataframe())
300+
print(f"Got {len(df)} accounts")
301+
```
302+
303+
```python
304+
# Comparison filters
305+
query = (client.query.builder("contact")
306+
.filter_eq("statecode", 0) # statecode eq 0
307+
.filter_gt("revenue", 1000000) # revenue gt 1000000
308+
.filter_contains("name", "Corp") # contains(name, 'Corp')
309+
.filter_in("statecode", [0, 1]) # Microsoft.Dynamics.CRM.In(...)
310+
.filter_between("revenue", 100000, 500000) # (revenue ge 100000 and revenue le 500000)
311+
.filter_null("telephone1") # telephone1 eq null
312+
)
313+
```
314+
315+
For complex logic (OR, NOT, grouping), use the composable expression tree with `where()`:
316+
317+
```python
318+
from PowerPlatform.Dataverse.models.filters import eq, gt, filter_in, between
319+
320+
# OR conditions: (statecode = 0 OR statecode = 1) AND revenue > 100k
321+
for record in (client.query.builder("account")
322+
.select("name", "revenue")
323+
.where((eq("statecode", 0) | eq("statecode", 1))
324+
& gt("revenue", 100000))
325+
.execute()):
326+
print(record["name"])
327+
328+
# NOT, between, and in operators
329+
for record in (client.query.builder("account")
330+
.where(~eq("statecode", 2)) # NOT inactive
331+
.where(between("revenue", 100000, 500000)) # revenue in range
332+
.execute()):
333+
print(record["name"])
334+
```
335+
336+
**Formatted values and annotations** -- request localized labels, currency symbols, and display names:
337+
338+
```python
339+
# Get formatted values (choice labels, currency, lookup names)
340+
for record in (client.query.builder("account")
341+
.select("name", "statecode", "revenue")
342+
.include_formatted_values()
343+
.execute()):
344+
status = record["statecode@OData.Community.Display.V1.FormattedValue"]
345+
print(f"{record['name']}: {status}")
346+
```
347+
348+
**Nested expand with options** -- expand navigation properties with `$select`, `$filter`, `$orderby`, and `$top`:
349+
350+
```python
351+
from PowerPlatform.Dataverse.models.query_builder import ExpandOption
352+
353+
# Expand related tasks with filtering and sorting
354+
for record in (client.query.builder("account")
355+
.select("name")
356+
.expand(ExpandOption("Account_Tasks")
357+
.select("subject", "createdon")
358+
.filter("contains(subject,'Task')")
359+
.order_by("createdon", descending=True)
360+
.top(5))
361+
.execute()):
362+
print(record["name"], record.get("Account_Tasks"))
363+
```
364+
365+
**Record count** -- include `$count=true` in the request:
366+
367+
```python
368+
# Request count alongside results
369+
results = (client.query.builder("account")
370+
.filter_eq("statecode", 0)
371+
.count()
372+
.execute())
373+
```
374+
375+
**SQL queries** provide an alternative read-only query syntax:
376+
377+
The **QueryBuilder** is the recommended way to query records. It provides a fluent, type-safe interface that generates correct OData queries automatically — no need to remember OData filter syntax.
378+
379+
```python
380+
# Fluent query builder (recommended)
381+
for record in (client.query.builder("account")
382+
.select("name", "revenue")
383+
.filter_eq("statecode", 0)
384+
.filter_gt("revenue", 1000000)
385+
.order_by("revenue", descending=True)
386+
.top(100)
387+
.page_size(50)
388+
.execute()):
389+
print(f"{record['name']}: {record['revenue']}")
390+
```
391+
392+
The QueryBuilder handles value formatting, column name casing, and OData syntax automatically. All filter methods are discoverable via IDE autocomplete:
393+
255394
```python
256395
# Comparison filters
257396
query = (client.query.builder("contact")

0 commit comments

Comments
 (0)