Skip to content

Commit b7b4a8b

Browse files
author
Samson Gebre
committed
feat: Enhance OData client with support for include annotations and pagination options
- Added `include_annotations` parameter to `_RecordGet` and `_RecordList` classes for OData requests. - Updated `_BatchClient` to handle new parameters in batch operations. - Enhanced `_ODataClient` methods to support `include_annotations`, `expand`, `page_size`, and `count` parameters. - Modified `BatchRecordOperations` to pass new parameters in batch record retrieval and listing methods. - Updated `RecordOperations` to include new parameters for retrieving and listing records. - Added unit tests to validate the new functionality for batch operations and record retrieval. - Implemented migration tool updates to handle changes in method signatures and ensure backward compatibility.
1 parent 030666c commit b7b4a8b

15 files changed

Lines changed: 1119 additions & 208 deletions

File tree

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

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ Use the PowerPlatform Dataverse Client Python SDK to interact with Microsoft Dat
2828
The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation
2929

3030
### Paging
31-
- Control page size with `page_size` parameter
31+
- Control page size with `page_size` parameter on `records.list()`, `records.list_pages()`, or `QueryBuilder.page_size()`
3232
- Use `top` parameter to limit total records returned
33-
- Simple streaming: `records.list_pages(table, filter, select)`yields one `QueryResult` per HTTP page (3 params only; use builder for advanced options)
34-
- Advanced streaming: `client.query.builder(table)....execute_pages()`full builder options, one `QueryResult` per page
33+
- **Preferred**: `client.query.builder(table)....execute_pages()`composable `where(col(...))` filters, formatted values, expand with nested selects, full pagination control
34+
- Simple streaming shortcut: `records.list_pages(table, *, filter, select, top, orderby, expand, page_size, count, include_annotations)`string-based OData filter only, yields one `QueryResult` per page
3535
- `execute(by_page=True/False)` is **deprecated** and emits `UserWarning`; use `execute_pages()` instead
36+
- `QueryBuilder.to_dataframe()` is **deprecated**; use `.execute().to_dataframe()` instead
3637

3738
### QueryResult
3839
- Returned by `records.list()`, `records.retrieve()`, `execute()`, and each page from `list_pages()` / `execute_pages()`
@@ -98,48 +99,81 @@ contact_ids = client.records.create("contact", contacts)
9899
# Get single record by ID
99100
account = client.records.retrieve("account", account_id, select=["name", "telephone1"])
100101

101-
# Query with filter — follows @odata.nextLink automatically (multiple HTTP requests if needed),
102-
# loads all matching records into memory, returns a single QueryResult.
103-
# Page size is Dataverse's default (~5000/page); use top to bound total records and round-trips.
104-
# For very large sets where memory is a concern, use records.list_pages() or execute_pages() instead.
105-
result = client.records.list(
106-
"account",
107-
select=["accountid", "name"], # select is case-insensitive (automatically lowercased)
108-
filter="statecode eq 0", # filter must use lowercase logical names (not transformed)
109-
top=100, # bounds both total records returned and HTTP round-trips
110-
)
102+
# Simple shortcut — use records.list() only for basic filter + select without composable logic.
103+
# Follows @odata.nextLink automatically and loads all matching records into memory.
104+
# For filtering, sorting, expansion, or formatted values, prefer client.query.builder() (see below).
105+
result = client.records.list("account", filter="statecode eq 0", select=["name", "accountid"])
111106
for record in result:
112107
print(record["name"])
108+
```
113109

114-
# Simple streaming — page-by-page (3 params only; use builder for ordering/expand/count)
115-
for page in client.records.list_pages(
116-
"account",
117-
select=["accountid", "name"],
118-
filter="statecode eq 0",
119-
):
120-
for record in page:
121-
print(record["name"])
110+
#### Query Builder (Preferred for Filtering, Sorting, Expand, Formatted Values)
122111

123-
# Advanced streaming — full builder options, one QueryResult per HTTP page
112+
Use `client.query.builder()` for any query that goes beyond simple filter + select. It provides composable `where(col(...))` expressions, formatted value support, nested expansion, and streaming — all with a fluent API.
113+
114+
```python
124115
from PowerPlatform.Dataverse.models.filters import col
116+
from PowerPlatform.Dataverse.models.query_builder import ExpandOption
117+
118+
# Basic query with composable filter and sort
119+
result = (client.query.builder("account")
120+
.select("accountid", "name", "statecode")
121+
.where(col("statecode") == 0)
122+
.order_by("name asc")
123+
.execute())
124+
for record in result:
125+
print(record["name"])
126+
127+
# Composable filters — AND / OR / NOT using Python operators
128+
result = (client.query.builder("contact")
129+
.select("fullname", "emailaddress1")
130+
.where((col("statecode") == 0) & (col("emailaddress1").contains("@contoso.com")))
131+
.execute())
132+
133+
# Formatted values — display labels for option sets, currency symbols, etc.
134+
result = (client.query.builder("account")
135+
.select("accountid", "name", "industrycode")
136+
.where(col("statecode") == 0)
137+
.include_formatted_values()
138+
.execute())
139+
for record in result:
140+
label = record.get("industrycode@OData.Community.Display.V1.FormattedValue")
141+
print(record["name"], label)
142+
143+
# Navigation property expansion with nested column select
144+
result = (client.query.builder("account")
145+
.select("name")
146+
.expand(ExpandOption("primarycontactid").select("fullname", "emailaddress1"))
147+
.where(col("statecode") == 0)
148+
.execute())
149+
for record in result:
150+
contact = record.get("primarycontactid", {})
151+
print(f"{record['name']} - {contact.get('fullname', 'N/A')}")
152+
153+
# Stream large result sets page-by-page (memory-efficient)
125154
for page in (client.query.builder("account")
126155
.select("accountid", "name")
127156
.where(col("statecode") == 0)
128-
.page_size(500) # optional: override Dataverse default page size
157+
.order_by("name asc")
158+
.page_size(500)
129159
.execute_pages()):
130160
for record in page:
131161
print(record["name"])
132162

133-
# Query with navigation property expansion — use the query builder (records.list() has no expand)
134-
from PowerPlatform.Dataverse.models.query_builder import ExpandOption
135-
from PowerPlatform.Dataverse.models.filters import col
136-
for record in (client.query.builder("account")
137-
.select("name")
138-
.expand(ExpandOption("primarycontactid").select("fullname"))
139-
.where(col("statecode") == 0)
140-
.execute()):
141-
contact = record.get("primarycontactid", {})
142-
print(f"{record['name']} - {contact.get('fullname', 'N/A')}")
163+
# Convert query results to a DataFrame
164+
df = (client.query.builder("account")
165+
.select("accountid", "name")
166+
.where(col("statecode") == 0)
167+
.execute()
168+
.to_dataframe())
169+
170+
# Limit total results
171+
result = client.query.builder("account").select("name").top(100).execute()
172+
173+
# Simple streaming shortcut via records.list_pages() (string filter only, same params as records.list())
174+
for page in client.records.list_pages("account", filter="statecode eq 0", select=["name"], page_size=500):
175+
for record in page:
176+
print(record["name"])
143177
```
144178

145179
#### Create Records with Lookup Bindings (@odata.bind)
@@ -212,18 +246,21 @@ client.records.delete("account", [id1, id2, id3], use_bulk_delete=True)
212246

213247
The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output.
214248

215-
> **Note:** `client.dataframe.get()` is deprecated. Use `client.query.builder(table).select(...).where(...).to_dataframe()` instead.
249+
> **Note:** `client.dataframe.get()` is deprecated. Use `client.query.builder(table).select(...).where(...).execute().to_dataframe()` instead. `QueryBuilder.to_dataframe()` (without `.execute()`) is also deprecated — always call `.execute()` first.
216250
217251
```python
218252
import pandas as pd
219253

220-
# Query records -- returns a single DataFrame (GA builder pattern)
254+
# Query records -- returns a single DataFrame (GA pattern: .execute().to_dataframe())
221255
from PowerPlatform.Dataverse.models.filters import col
222-
df = client.query.builder("account").where(col("statecode") == 0).select("name").to_dataframe()
256+
df = client.query.builder("account").where(col("statecode") == 0).select("name").execute().to_dataframe()
223257
print(f"Got {len(df)} rows")
224258

225259
# Limit results with top
226-
df = client.query.builder("account").select("name").top(100).to_dataframe()
260+
df = client.query.builder("account").select("name").top(100).execute().to_dataframe()
261+
262+
# Via records.list() (simpler for basic queries)
263+
df = client.records.list("account", filter="statecode eq 0", select=["name"]).to_dataframe()
227264

228265
# Fetch single record as one-row DataFrame
229266
df = client.records.retrieve("account", account_id, select=["name"]).to_dataframe()
@@ -444,8 +481,8 @@ Use `client.batch` to send multiple operations in one HTTP request. All batch me
444481
batch = client.batch.new()
445482
batch.records.create("account", {"name": "Contoso"})
446483
batch.records.update("account", account_id, {"telephone1": "555-0100"})
447-
batch.records.retrieve("account", account_id, select=["name"]) # single record (GA)
448-
batch.records.list("account", filter="statecode eq 0", top=50) # multi-record, single page
484+
batch.records.retrieve("account", account_id, select=["name"], include_annotations="OData.Community.Display.V1.FormattedValue") # single record
485+
batch.records.list("account", filter="statecode eq 0", select=["name"], orderby=["name asc"], top=50, page_size=25, count=True) # multi-record, single page
449486
batch.query.sql("SELECT TOP 5 name FROM account")
450487

451488
result = batch.execute()
@@ -530,16 +567,17 @@ except ValidationError as e:
530567

531568
### Performance Optimization
532569

533-
1. **Use bulk operations** - Pass lists to create/update/delete for automatic optimization
534-
2. **Specify select fields** - Limit returned columns to reduce payload size
535-
3. **Control page size** - Use `top` and `page_size` parameters appropriately
536-
4. **Reuse client instances** - Don't create new clients for each operation
537-
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
538-
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
539-
7. **Always include customization prefix** for custom tables/columns
540-
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`)
541-
9. **Test in non-production environments** first
542-
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
570+
1. **Prefer `client.query.builder()` for any non-trivial query** — use the builder for filtering, sorting, expansion, or formatted values; `records.list()` is a convenience shortcut for simple filter+select only
571+
2. **Use bulk operations** - Pass lists to create/update/delete for automatic optimization
572+
3. **Specify select fields** - Limit returned columns to reduce payload size
573+
4. **Control page size** - Use `top` and `page_size` parameters appropriately; use `execute_pages()` for large sets
574+
5. **Reuse client instances** - Don't create new clients for each operation
575+
6. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
576+
7. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
577+
8. **Always include customization prefix** for custom tables/columns
578+
9. **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`)
579+
10. **Test in non-production environments** first
580+
11. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
543581

544582
## Additional Resources
545583

@@ -552,9 +590,10 @@ Load these resources as needed during development:
552590

553591
## Key Reminders
554592

555-
1. **Schema names are required** - Never use display names
556-
2. **Custom tables need prefixes** - Include customization prefix (e.g., "new_")
557-
3. **Filter is case-sensitive** - Use lowercase logical names
558-
4. **Bulk operations are encouraged** - Pass lists for optimization
559-
5. **No trailing slashes in URLs** - Format: `https://org.crm.dynamics.com`
560-
6. **Structured errors** - Check `is_transient` for retry logic
593+
1. **Use `client.query.builder()` for queries** — it's the primary query pattern; `records.list()` is a shortcut for trivial filter+select only
594+
2. **Schema names are required** - Never use display names
595+
3. **Custom tables need prefixes** - Include customization prefix (e.g., "new_")
596+
4. **Filter is case-sensitive** - Use lowercase logical names
597+
5. **Bulk operations are encouraged** - Pass lists for optimization
598+
6. **No trailing slashes in URLs** - Format: `https://org.crm.dynamics.com`
599+
7. **Structured errors** - Check `is_transient` for retry logic

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- `client.records.retrieve(table, record_id)` — fetch a single record by GUID; returns `None` on 404 instead of raising (#175)
12-
- `client.records.list(table, filter, select, top)` — eager fetch returning a flat `QueryResult`; GA replacement for `records.get()` without a record ID (#175)
13-
- `client.records.list_pages(table, filter, select, top)` — lazy iterator yielding one `QueryResult` per HTTP page; streaming counterpart to `list()` (#175)
11+
- `client.records.retrieve(table, record_id, *, select, include_annotations)` — fetch a single record by GUID; returns `None` on 404 instead of raising; `include_annotations` maps to the `Prefer: odata.include-annotations` header for formatted values and lookup labels (#175)
12+
- `client.records.list(table, *, filter, select, top, orderby, expand, page_size, count, include_annotations)` — eager fetch returning a flat `QueryResult`; GA replacement for `records.get()` without a record ID; `page_size` controls `Prefer: odata.maxpagesize`, `count=True` adds `$count=true`, `include_annotations` requests formatted values (#175)
13+
- `client.records.list_pages(table, *, filter, select, top, orderby, expand, page_size, count, include_annotations)` — lazy iterator yielding one `QueryResult` per HTTP page; streaming counterpart to `list()`; same parameter set (#175)
14+
- `client.batch.records.retrieve()` and `client.batch.records.list()` now accept the same `include_annotations`, `orderby`, `expand`, `page_size`, and `count` parameters as their non-batch counterparts (#175)
1415
- `client.query.fetchxml(xml)` — FetchXML support returning an inert `FetchXmlQuery`; no HTTP request is made until `.execute()` or `.execute_pages()` is called (#175)
1516
- `FetchXmlQuery` implements the correct Dataverse paging cookie algorithm: annotation parsed as outer XML, `pagingcookie` attribute double URL-decoded, server-supplied `pagenumber` used for next page, `morerecords` handled as both `bool` and `"true"` string, `UserWarning` emitted on simple paging fallback, 32,768-character URL limit enforced (documented Dataverse GET cap), 10,000-page circuit breaker against runaway iteration (#175)
1617
- `QueryBuilder.execute_pages()` — lazy per-page streaming returning one `QueryResult` per HTTP page; replaces deprecated `execute(by_page=True)` (#175)
@@ -19,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1920
- `DataverseModel` structural `Protocol` (`models/protocol.py`) — implement on any entity class to enable typed integration with CRUD operations without specifying table names or serializing manually (#175)
2021
- `col()`, `raw()`, `QueryResult`, and `DataverseModel` exported from the top-level `PowerPlatform.Dataverse` package (#175)
2122
- v0→v1 migration tool: `tools/migrate_v0_to_v1.py` rewrites v0 call sites to the v1 API with `--dry-run` support; covers `create`, `update`, `delete`, `get`, `list`, `fetchxml`, and query builder patterns (#175)
23+
- Migration tool now auto-rewrites `QueryBuilder.to_dataframe()``.execute().to_dataframe()` (inserts `.execute()` when receiver is a recognised builder chain); output improved with `[NEEDS-MANUAL]` label for files that have no auto-rewrites but require manual attention, and a trailing note on `[MIGRATED]` lines when manual items remain (#175)
2224

2325
### Changed
2426
- `QueryBuilder.execute()` now returns a flat `QueryResult` (all pages collected eagerly) instead of `Iterable[Record]` (#175)

0 commit comments

Comments
 (0)