Skip to content

Commit 8b7007d

Browse files
tpellissierclaude
andcommitted
Add QueryBuilder examples to walkthrough and README
- README: Promote QueryBuilder as primary query method with fluent, expression tree, and filter_in/between examples; demote raw OData to fallback - Walkthrough: Add Section 7 with 5 QueryBuilder demos (basic fluent, filter_in, filter_between, where() expression tree, combined + paging) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6fb4af9 commit 8b7007d

File tree

2 files changed

+176
-35
lines changed

2 files changed

+176
-35
lines changed

README.md

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +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-
- [Query data](#query-data)
27+
- [Query data](#query-data) *(QueryBuilder, SQL, raw OData)*
2828
- [Table management](#table-management)
2929
- [Relationship management](#relationship-management)
3030
- [File operations](#file-operations)
@@ -36,7 +36,8 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
3636

3737
- **🔄 CRUD Operations**: Create, read, update, and delete records with support for bulk operations and automatic retry
3838
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, `UpsertMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
39-
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
39+
- **🔍 Fluent QueryBuilder**: Type-safe query construction with method chaining, composable filter expressions, and automatic OData generation
40+
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
4041
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
4142
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
4243
- **📎 File Operations**: Upload files to Dataverse file columns with automatic chunking for large files
@@ -113,7 +114,7 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
113114
| Concept | Description |
114115
|---------|-------------|
115116
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, and `files` namespaces |
116-
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.files` (file uploads) |
117+
| **Namespaces** | Operations are organized into `client.records` (CRUD), `client.query` (QueryBuilder & SQL), `client.tables` (metadata), and `client.files` (file uploads) |
117118
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
118119
| **Schema names** | Use table schema names (`"account"`, `"new_MyTestTable"`) and column schema names (`"name"`, `"new_MyTestColumn"`). See: [Table definitions in Microsoft Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/entity-metadata) |
119120
| **Bulk Operations** | Efficient bulk processing for multiple records with automatic optimization |
@@ -232,42 +233,83 @@ client.records.upsert("account", [
232233

233234
### Query data
234235

236+
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.
237+
238+
```python
239+
# Fluent query builder (recommended)
240+
for page in (client.query.builder("account")
241+
.select("name", "revenue")
242+
.filter_eq("statecode", 0)
243+
.filter_gt("revenue", 1000000)
244+
.order_by("revenue", descending=True)
245+
.top(100)
246+
.page_size(50)
247+
.execute()):
248+
for record in page:
249+
print(f"{record['name']}: {record['revenue']}")
250+
```
251+
252+
The QueryBuilder handles value formatting, column name casing, and OData syntax automatically. All filter methods are discoverable via IDE autocomplete:
253+
254+
```python
255+
# Comparison filters
256+
query = (client.query.builder("contact")
257+
.filter_eq("statecode", 0) # statecode eq 0
258+
.filter_gt("revenue", 1000000) # revenue gt 1000000
259+
.filter_contains("name", "Corp") # contains(name, 'Corp')
260+
.filter_in("statecode", [0, 1]) # statecode in (0, 1)
261+
.filter_between("revenue", 100000, 500000) # (revenue ge 100000 and revenue le 500000)
262+
.filter_null("telephone1") # telephone1 eq null
263+
)
264+
```
265+
266+
For complex logic (OR, NOT, grouping), use the composable expression tree with `where()`:
267+
268+
```python
269+
from PowerPlatform.Dataverse.models.filters import eq, gt, filter_in, between
270+
271+
# OR conditions: (statecode = 0 OR statecode = 1) AND revenue > 100k
272+
for page in (client.query.builder("account")
273+
.select("name", "revenue")
274+
.where((eq("statecode", 0) | eq("statecode", 1))
275+
& gt("revenue", 100000))
276+
.execute()):
277+
for record in page:
278+
print(record["name"])
279+
280+
# NOT, between, and in operators
281+
for page in (client.query.builder("account")
282+
.where(~eq("statecode", 2)) # NOT inactive
283+
.where(between("revenue", 100000, 500000)) # revenue in range
284+
.execute()):
285+
for record in page:
286+
print(record["name"])
287+
```
288+
289+
**SQL queries** provide an alternative read-only query syntax:
290+
235291
```python
236-
# SQL query (read-only)
237292
results = client.query.sql(
238293
"SELECT TOP 10 accountid, name FROM account WHERE statecode = 0"
239294
)
240295
for record in results:
241296
print(record["name"])
297+
```
298+
299+
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string:
242300

243-
# OData query with paging
244-
# Note: filter and expand parameters are case sensitive
301+
```python
245302
for page in client.records.get(
246303
"account",
247-
select=["accountid", "name"], # select is case-insensitive (automatically lowercased)
248-
filter="statecode eq 0", # filter must use lowercase logical names (not transformed)
304+
select=["name"],
305+
filter="statecode eq 0", # Raw OData: column names must be lowercase
306+
expand=["primarycontactid"], # Navigation properties are case-sensitive
249307
top=100,
250308
):
251309
for record in page:
252310
print(record["name"])
253-
254-
# Query with navigation property expansion (case-sensitive!)
255-
for page in client.records.get(
256-
"account",
257-
select=["name"],
258-
expand=["primarycontactid"], # Navigation property names are case-sensitive
259-
filter="statecode eq 0", # Column names must be lowercase logical names
260-
):
261-
for account in page:
262-
contact = account.get("primarycontactid", {})
263-
print(f"{account['name']} - Contact: {contact.get('fullname', 'N/A')}")
264311
```
265312

266-
> **Important**: When using `filter` and `expand` parameters:
267-
> - **`filter`**: Column names must use exact lowercase logical names (e.g., `"statecode eq 0"`, not `"StateCode eq 0"`)
268-
> - **`expand`**: Navigation property names are case-sensitive and must match the exact server names
269-
> - **`select`** and **`orderby`**: Case-insensitive; automatically converted to lowercase
270-
271313
### Table management
272314

273315
```python

examples/advanced/walkthrough.py

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
This example shows:
88
- Table creation with various column types including enums
99
- Single and multiple record CRUD operations
10-
- Querying with filtering, paging, and SQL
10+
- Querying with filtering, paging, QueryBuilder, and SQL
1111
- Picklist label-to-value conversion
1212
- Column management
1313
- Cleanup
@@ -24,6 +24,7 @@
2424
from azure.identity import InteractiveBrowserCredential
2525
from PowerPlatform.Dataverse.client import DataverseClient
2626
from PowerPlatform.Dataverse.core.errors import MetadataError
27+
from PowerPlatform.Dataverse.models.filters import eq, gt, between
2728
import requests
2829

2930

@@ -251,10 +252,107 @@ def main():
251252
print(f" Page {page_num}: {len(page)} records - IDs: {record_ids}")
252253

253254
# ============================================================================
254-
# 7. SQL QUERY
255+
# 7. QUERYBUILDER - FLUENT QUERIES
255256
# ============================================================================
256257
print("\n" + "=" * 80)
257-
print("7. SQL Query")
258+
print("7. QueryBuilder - Fluent Queries")
259+
print("=" * 80)
260+
261+
# Basic fluent query: active records sorted by amount
262+
log_call("client.query.builder(...).select().filter_eq().order_by().execute()")
263+
print("Querying incomplete records ordered by amount (fluent builder)...")
264+
qb_records = []
265+
for page in backoff(
266+
lambda: client.query.builder(table_name)
267+
.select("new_Title", "new_Amount", "new_Priority")
268+
.filter_eq("new_Completed", False)
269+
.order_by("new_Amount", descending=True)
270+
.top(10)
271+
.execute()
272+
):
273+
qb_records.extend(page)
274+
print(f"[OK] QueryBuilder found {len(qb_records)} incomplete records:")
275+
for rec in qb_records[:5]:
276+
print(f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')}")
277+
278+
# filter_in: records with specific priorities
279+
log_call("client.query.builder(...).filter_in('new_Priority', [HIGH, LOW]).execute()")
280+
print("Querying records with HIGH or LOW priority (filter_in)...")
281+
priority_records = []
282+
for page in backoff(
283+
lambda: client.query.builder(table_name)
284+
.select("new_Title", "new_Priority")
285+
.filter_in("new_Priority", [Priority.HIGH, Priority.LOW])
286+
.execute()
287+
):
288+
priority_records.extend(page)
289+
print(f"[OK] Found {len(priority_records)} records with HIGH or LOW priority")
290+
for rec in priority_records[:5]:
291+
print(f" - '{rec.get('new_title')}' Priority={rec.get('new_priority')}")
292+
293+
# filter_between: amount in a range
294+
log_call("client.query.builder(...).filter_between('new_Amount', 500, 1500).execute()")
295+
print("Querying records with amount between 500 and 1500 (filter_between)...")
296+
range_records = []
297+
for page in backoff(
298+
lambda: client.query.builder(table_name)
299+
.select("new_Title", "new_Amount")
300+
.filter_between("new_Amount", 500, 1500)
301+
.execute()
302+
):
303+
range_records.extend(page)
304+
print(f"[OK] Found {len(range_records)} records with amount in [500, 1500]")
305+
for rec in range_records:
306+
print(f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')}")
307+
308+
# Composable expression tree with where()
309+
log_call("client.query.builder(...).where((eq(...) | eq(...)) & gt(...)).execute()")
310+
print("Querying with composable expression tree (where)...")
311+
expr_records = []
312+
for page in backoff(
313+
lambda: client.query.builder(table_name)
314+
.select("new_Title", "new_Amount", "new_Quantity")
315+
.where(
316+
(eq("new_Completed", False) & gt("new_Amount", 100))
317+
)
318+
.order_by("new_Amount", descending=True)
319+
.top(5)
320+
.execute()
321+
):
322+
expr_records.extend(page)
323+
print(f"[OK] Expression tree query found {len(expr_records)} records:")
324+
for rec in expr_records:
325+
print(
326+
f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')} Qty={rec.get('new_quantity')}"
327+
)
328+
329+
# Combined: fluent filters + expression tree + paging
330+
log_call("client.query.builder(...).filter_eq().where(between()).page_size().execute()")
331+
print("Querying with combined fluent + expression filters and paging...")
332+
combined_page_count = 0
333+
combined_record_count = 0
334+
for page in backoff(
335+
lambda: client.query.builder(table_name)
336+
.select("new_Title", "new_Quantity")
337+
.filter_eq("new_Completed", False)
338+
.where(between("new_Quantity", 1, 15))
339+
.order_by("new_Quantity")
340+
.page_size(3)
341+
.execute()
342+
):
343+
combined_page_count += 1
344+
combined_record_count += len(page)
345+
titles = [r.get("new_title", "?") for r in page]
346+
print(f" Page {combined_page_count}: {len(page)} records - {titles}")
347+
print(
348+
f"[OK] Combined query: {combined_record_count} records across {combined_page_count} page(s)"
349+
)
350+
351+
# ============================================================================
352+
# 8. SQL QUERY
353+
# ============================================================================
354+
print("\n" + "=" * 80)
355+
print("8. SQL Query")
258356
print("=" * 80)
259357

260358
log_call(f"client.query.sql('SELECT new_title, new_quantity FROM {table_name} WHERE new_completed = 1')")
@@ -268,10 +366,10 @@ def main():
268366
print(f"[WARN] SQL query failed (known server-side bug): {str(e)}")
269367

270368
# ============================================================================
271-
# 8. PICKLIST LABEL CONVERSION
369+
# 9. PICKLIST LABEL CONVERSION
272370
# ============================================================================
273371
print("\n" + "=" * 80)
274-
print("8. Picklist Label Conversion")
372+
print("9. Picklist Label Conversion")
275373
print("=" * 80)
276374

277375
log_call(f"client.records.create('{table_name}', {{'new_Priority': 'High'}})")
@@ -289,10 +387,10 @@ def main():
289387
print(f" new_Priority@FormattedValue: {retrieved.get('new_priority@OData.Community.Display.V1.FormattedValue')}")
290388

291389
# ============================================================================
292-
# 9. COLUMN MANAGEMENT
390+
# 10. COLUMN MANAGEMENT
293391
# ============================================================================
294392
print("\n" + "=" * 80)
295-
print("9. Column Management")
393+
print("10. Column Management")
296394
print("=" * 80)
297395

298396
log_call(f"client.tables.add_columns('{table_name}', {{'new_Notes': 'string'}})")
@@ -305,10 +403,10 @@ def main():
305403
print(f"[OK] Deleted column: new_Notes")
306404

307405
# ============================================================================
308-
# 10. DELETE OPERATIONS
406+
# 11. DELETE OPERATIONS
309407
# ============================================================================
310408
print("\n" + "=" * 80)
311-
print("10. Delete Operations")
409+
print("11. Delete Operations")
312410
print("=" * 80)
313411

314412
# Single delete
@@ -323,10 +421,10 @@ def main():
323421
print(f" (Deleting {len(paging_ids)} paging demo records)")
324422

325423
# ============================================================================
326-
# 11. CLEANUP
424+
# 12. CLEANUP
327425
# ============================================================================
328426
print("\n" + "=" * 80)
329-
print("11. Cleanup")
427+
print("12. Cleanup")
330428
print("=" * 80)
331429

332430
log_call(f"client.tables.delete('{table_name}')")
@@ -352,6 +450,7 @@ def main():
352450
print(" [OK] Reading records by ID and with filters")
353451
print(" [OK] Single and multiple record updates")
354452
print(" [OK] Paging through large result sets")
453+
print(" [OK] QueryBuilder fluent queries (filter_eq, filter_in, filter_between, where)")
355454
print(" [OK] SQL queries")
356455
print(" [OK] Picklist label-to-value conversion")
357456
print(" [OK] Column management")

0 commit comments

Comments
 (0)