Skip to content

Commit e565c9f

Browse files
Abel Milashclaude
andcommitted
Merge main into users/abelmilash/batch_chunking
Brings in SQL support (#141), display_name for table creation (#164), relationship/lookup APIs, QueryBuilder additions, and associated tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 9a1f490 + 7441b5c commit e565c9f

26 files changed

Lines changed: 4442 additions & 53 deletions

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.1.0b9] - 2026-04-28
11+
12+
### Added
13+
- `client.dataframe.sql()`: execute a SQL SELECT and get results directly as a pandas DataFrame (#141)
14+
- Schema discovery: `client.query.list_columns()`, `client.query.list_relationships()`, and `client.query.list_table_relationships()` to inspect table columns and relationships from metadata (#141)
15+
- SQL query helpers: `client.query.sql_columns()`, `client.query.sql_select()`, `client.query.sql_joins()`, and `client.query.sql_join()` to auto-build SQL statements from metadata without knowing column or join syntax manually (#141)
16+
- OData query helpers: `client.query.odata_select()`, `client.query.odata_expands()`, `client.query.odata_expand()`, and `client.query.odata_bind()` to auto-discover navigation property names and build `@odata.bind` values from metadata (#141)
17+
- `SELECT *` raises a `ValidationError` with a clear message instead of sending the query to the server, which does not support wildcard selects; use `client.query.sql_columns()` to discover available columns (#141)
18+
- SQL safety guardrails: write statements (`INSERT`, `UPDATE`, `DELETE`, etc.) raise `ValidationError` before hitting the server; cartesian joins (`FROM a, b`) and leading-wildcard `LIKE` patterns emit `UserWarning` (#141)
19+
- `client.tables.create()` now accepts an optional `display_name` parameter to set a human-readable label for the table in Dataverse; defaults to the schema name when omitted (#164)
20+
- Opt-in HTTP diagnostics logging: pass a `LogConfig` to `DataverseConfig` to log all HTTP request and response traffic to rotating local log files with automatic redaction of sensitive headers (`Authorization`, etc.) (#135)
21+
22+
### Changed
23+
- `client.tables.create_lookup_field()` now automatically lowercases `referencing_table` and `referenced_table` to valid Dataverse logical names; callers no longer need to call `.lower()` manually (#141)
24+
1025
## [0.1.0b8] - 2026-04-10
1126

1227
### Added
@@ -110,7 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
110125
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
111126
- HTTP retry logic with exponential backoff for resilient operations (#72)
112127

113-
[Unreleased]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b8...HEAD
128+
[Unreleased]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b9...HEAD
129+
[0.1.0b9]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b8...v0.1.0b9
114130
[0.1.0b8]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b7...v0.1.0b8
115131
[0.1.0b7]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b6...v0.1.0b7
116132
[0.1.0b6]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b5...v0.1.0b6

README.md

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,14 @@ client.dataframe.update("account", df, id_column="accountid", clear_nulls=True)
282282

283283
# Delete records by passing a Series of GUIDs
284284
client.dataframe.delete("account", new_accounts["accountid"])
285+
286+
# SQL query directly to DataFrame (supports JOINs, aggregates, GROUP BY)
287+
df = client.dataframe.sql(
288+
"SELECT a.name, COUNT(c.contactid) as contacts "
289+
"FROM account a "
290+
"JOIN contact c ON a.accountid = c.parentcustomerid "
291+
"GROUP BY a.name"
292+
)
285293
```
286294

287295
### Query data
@@ -385,19 +393,65 @@ results = (client.query.builder("account")
385393
.execute())
386394
```
387395

388-
**SQL queries** provide an alternative read-only query syntax:
396+
**SQL queries** provide an alternative read-only query syntax with support for
397+
JOINs, aggregates, GROUP BY, DISTINCT, and OFFSET FETCH pagination:
389398

390399
```python
400+
# Basic query
391401
results = client.query.sql(
392402
"SELECT TOP 10 accountid, name FROM account WHERE statecode = 0"
393403
)
394-
for record in results:
395-
print(record["name"])
404+
405+
# JOINs and aggregates work
406+
results = client.query.sql(
407+
"SELECT a.name, COUNT(c.contactid) as cnt "
408+
"FROM account a "
409+
"JOIN contact c ON a.accountid = c.parentcustomerid "
410+
"GROUP BY a.name"
411+
)
412+
413+
# SQL results directly as a DataFrame
414+
df = client.dataframe.sql(
415+
"SELECT name, revenue FROM account ORDER BY revenue DESC"
416+
)
417+
418+
# SQL helpers: discover columns and JOINs from metadata
419+
cols = client.query.sql_select("account") # "accountid, name, revenue, ..."
420+
join = client.query.sql_join("contact", "account", from_alias="c", to_alias="a")
421+
# Returns: "JOIN account a ON c.parentcustomerid = a.accountid"
422+
423+
# Build queries using helpers -- no OData knowledge needed
424+
sql = f"SELECT TOP 10 c.fullname, a.name FROM contact c {join}"
425+
df = client.dataframe.sql(sql)
426+
427+
# Discover all possible JOINs from a table (including polymorphic)
428+
joins = client.query.sql_joins("opportunity")
429+
for j in joins:
430+
print(f"{j['column']:30s} -> {j['target']}.{j['target_pk']}")
396431
```
397432

398-
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string:
433+
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string. The SDK provides helpers to eliminate the most error-prone parts:
399434

400435
```python
436+
# Discover columns for $select (returns list ready for select= parameter)
437+
cols = client.query.odata_select("account")
438+
for page in client.records.get("account", select=cols, top=10):
439+
...
440+
441+
# Discover $expand navigation properties (auto-resolves PascalCase names)
442+
nav = client.query.odata_expand("contact", "account")
443+
# Returns: "parentcustomerid_account"
444+
for page in client.records.get("contact", select=["fullname"], expand=[nav], top=5):
445+
for r in page:
446+
acct = r.get(nav) or {}
447+
print(f"{r['fullname']} -> {acct.get('name')}")
448+
449+
# Build @odata.bind for lookup fields (no manual name construction)
450+
bind = client.query.odata_bind("contact", "account", account_id)
451+
# Returns: {"parentcustomerid_account@odata.bind": "/accounts(guid)"}
452+
client.records.create("contact", {"firstname": "Jane", **bind})
453+
454+
# Raw OData query with manual parameters
401455
for page in client.records.get(
402456
"account",
403457
select=["name"],
@@ -447,6 +501,18 @@ client.tables.add_columns("new_Product", {"new_Category": "string"})
447501
# Remove columns
448502
client.tables.remove_columns("new_Product", ["new_Category"])
449503

504+
# List all columns (attributes) for a table to discover schema
505+
columns = client.tables.list_columns("account")
506+
for col in columns:
507+
print(f"{col['LogicalName']} ({col.get('AttributeType')})")
508+
509+
# List only specific properties
510+
columns = client.tables.list_columns(
511+
"account",
512+
select=["LogicalName", "SchemaName", "AttributeType"],
513+
filter="AttributeType eq 'String'",
514+
)
515+
450516
# Clean up
451517
client.tables.delete("new_Product")
452518
```
@@ -499,6 +565,16 @@ rel = client.tables.get_relationship("new_Department_Employee")
499565
if rel:
500566
print(f"Found: {rel['SchemaName']}")
501567

568+
# List all relationships
569+
rels = client.tables.list_relationships()
570+
for rel in rels:
571+
print(f"{rel['SchemaName']} ({rel.get('@odata.type')})")
572+
573+
# List relationships for a specific table (one-to-many + many-to-one + many-to-many)
574+
account_rels = client.tables.list_table_relationships("account")
575+
for rel in account_rels:
576+
print(f"{rel['SchemaName']} -> {rel.get('@odata.type')}")
577+
502578
# Delete a relationship
503579
client.tables.delete_relationship(result['relationship_id'])
504580
```

examples/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ Deep-dive into production-ready patterns and specialized functionality:
4040
- Column metadata management and multi-language support
4141
- Interactive cleanup and best practices
4242

43+
- **`sql_examples.py`** - **SQL QUERY END-TO-END** 🔍
44+
- Schema discovery before writing SQL (list_columns, list_relationships)
45+
- Full SQL capabilities: SELECT, WHERE, TOP, ORDER BY, LIKE, IN, BETWEEN
46+
- JOINs (INNER, LEFT, multi-table), GROUP BY, DISTINCT, aggregates
47+
- OFFSET FETCH for server-side pagination
48+
- Polymorphic lookups via SQL (ownerid, customerid, createdby)
49+
- SQL read -> DataFrame transform -> SDK write-back (full round-trip)
50+
- SQL-driven bulk create, update, and delete patterns
51+
- SQL to DataFrame via `client.dataframe.sql()`
52+
- Limitations with SDK fallbacks (writes, subqueries, functions)
53+
- Complete reference table: SQL vs SDK method mapping
54+
4355
- **`file_upload.py`** - **FILE OPERATIONS** 📎
4456
- File upload to Dataverse file columns with chunking
4557
- Advanced file handling patterns
@@ -68,13 +80,17 @@ python examples/basic/functional_testing.py
6880
```bash
6981
# Comprehensive walkthrough with production patterns
7082
python examples/advanced/walkthrough.py
83+
84+
# SQL queries end-to-end with SDK fallbacks for unsupported operations
85+
python examples/advanced/sql_examples.py
7186
```
7287

7388
## 🎯 Quick Start Recommendations
7489

7590
- **New to the SDK?** → Start with `examples/basic/installation_example.py`
7691
- **Need to test/validate?** → Use `examples/basic/functional_testing.py`
7792
- **Want to see all features?** → Run `examples/advanced/walkthrough.py`
93+
- **Using SQL queries?** → Run `examples/advanced/sql_examples.py`
7894
- **Building production apps?** → Study patterns in `examples/advanced/walkthrough.py`
7995

8096
## 📋 Prerequisites

examples/advanced/relationships.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def delete_relationship_if_exists(client, schema_name):
4242
"""Delete a relationship by schema name if it exists."""
4343
rel = client.tables.get_relationship(schema_name)
4444
if rel:
45-
rel_id = rel.get("MetadataId")
45+
rel_id = rel.relationship_id
4646
if rel_id:
4747
client.tables.delete_relationship(rel_id)
4848
print(f" (Cleaned up existing relationship: {schema_name})")
@@ -236,11 +236,11 @@ def _run_example(client):
236236
)
237237
)
238238

239-
print(f"[OK] Created relationship: {result['relationship_schema_name']}")
240-
print(f" Lookup field: {result['lookup_schema_name']}")
241-
print(f" Relationship ID: {result['relationship_id']}")
239+
print(f"[OK] Created relationship: {result.relationship_schema_name}")
240+
print(f" Lookup field: {result.lookup_schema_name}")
241+
print(f" Relationship ID: {result.relationship_id}")
242242

243-
rel_id_1 = result["relationship_id"]
243+
rel_id_1 = result.relationship_id
244244

245245
# ============================================================================
246246
# 5. CREATE LOOKUP FIELD (Convenience Method)
@@ -265,10 +265,10 @@ def _run_example(client):
265265
)
266266
)
267267

268-
print(f"[OK] Created lookup using convenience method: {result2['lookup_schema_name']}")
269-
print(f" Relationship: {result2['relationship_schema_name']}")
268+
print(f"[OK] Created lookup using convenience method: {result2.lookup_schema_name}")
269+
print(f" Relationship: {result2.relationship_schema_name}")
270270

271-
rel_id_2 = result2["relationship_id"]
271+
rel_id_2 = result2.relationship_id
272272

273273
# ============================================================================
274274
# 6. CREATE MANY-TO-MANY RELATIONSHIP
@@ -292,10 +292,10 @@ def _run_example(client):
292292
)
293293
)
294294

295-
print(f"[OK] Created M:N relationship: {result3['relationship_schema_name']}")
296-
print(f" Relationship ID: {result3['relationship_id']}")
295+
print(f"[OK] Created M:N relationship: {result3.relationship_schema_name}")
296+
print(f" Relationship ID: {result3.relationship_id}")
297297

298-
rel_id_3 = result3["relationship_id"]
298+
rel_id_3 = result3.relationship_id
299299

300300
# ============================================================================
301301
# 7. QUERY RELATIONSHIP METADATA
@@ -308,21 +308,21 @@ def _run_example(client):
308308

309309
rel_metadata = client.tables.get_relationship("new_Department_Employee")
310310
if rel_metadata:
311-
print(f"[OK] Found relationship: {rel_metadata.get('SchemaName')}")
312-
print(f" Type: {rel_metadata.get('@odata.type')}")
313-
print(f" Referenced Entity: {rel_metadata.get('ReferencedEntity')}")
314-
print(f" Referencing Entity: {rel_metadata.get('ReferencingEntity')}")
311+
print(f"[OK] Found relationship: {rel_metadata.relationship_schema_name}")
312+
print(f" Type: {rel_metadata.relationship_type}")
313+
print(f" Referenced Entity: {rel_metadata.referenced_entity}")
314+
print(f" Referencing Entity: {rel_metadata.referencing_entity}")
315315
else:
316316
print(" Relationship not found")
317317

318318
log_call("Retrieving M:N relationship by schema name")
319319

320320
m2m_metadata = client.tables.get_relationship("new_employee_project")
321321
if m2m_metadata:
322-
print(f"[OK] Found relationship: {m2m_metadata.get('SchemaName')}")
323-
print(f" Type: {m2m_metadata.get('@odata.type')}")
324-
print(f" Entity 1: {m2m_metadata.get('Entity1LogicalName')}")
325-
print(f" Entity 2: {m2m_metadata.get('Entity2LogicalName')}")
322+
print(f"[OK] Found relationship: {m2m_metadata.relationship_schema_name}")
323+
print(f" Type: {m2m_metadata.relationship_type}")
324+
print(f" Entity 1: {m2m_metadata.entity1_logical_name}")
325+
print(f" Entity 2: {m2m_metadata.entity2_logical_name}")
326326
else:
327327
print(" Relationship not found")
328328

0 commit comments

Comments
 (0)