Skip to content

Commit c9d77f1

Browse files
tpellissierclaude
andcommitted
Address PR review feedback: fix security issue, update README
- Fix OData filter injection vulnerability in _get_relationship by using _escape_odata_quotes() to sanitize schema_name input - Fix late binding closure bug in relationships.py cleanup loop - Remove unused imports (HttpError, MetadataError, pytest) - Initialize relationship IDs to None for cleanup safety - Add relationship management section to README with examples - Update docstring examples to use intuitive Department/Employee/Project scenario with helpful comments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1387796 commit c9d77f1

5 files changed

Lines changed: 97 additions & 46 deletions

File tree

README.md

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
2525
- [Bulk operations](#bulk-operations)
2626
- [Query data](#query-data)
2727
- [Table management](#table-management)
28+
- [Relationship management](#relationship-management)
2829
- [File operations](#file-operations)
2930
- [Next steps](#next-steps)
3031
- [Troubleshooting](#troubleshooting)
@@ -36,6 +37,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
3637
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
3738
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
3839
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
40+
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
3941
- **📎 File Operations**: Upload files to Dataverse file columns with automatic chunking for large files
4042
- **🔐 Azure Identity**: Built-in authentication using Azure Identity credential providers with comprehensive support
4143
- **🛡️ Error Handling**: Structured exception hierarchy with detailed error context and retry guidance
@@ -235,9 +237,74 @@ client.delete_columns("new_Product", ["new_Category"])
235237
client.delete_table("new_Product")
236238
```
237239

238-
> **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`).
240+
> **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`).
239241
> This ensures explicit, predictable naming and aligns with Dataverse metadata requirements.
240242
243+
### Relationship management
244+
245+
Create relationships between tables using the relationship API. For a complete working example, see [examples/advanced/relationships.py](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/relationships.py).
246+
247+
```python
248+
from PowerPlatform.Dataverse.models.metadata import (
249+
LookupAttributeMetadata,
250+
OneToManyRelationshipMetadata,
251+
ManyToManyRelationshipMetadata,
252+
Label,
253+
LocalizedLabel,
254+
)
255+
256+
# Create a one-to-many relationship: Department (1) -> Employee (N)
257+
# This adds a "Department" lookup field to the Employee table
258+
lookup = LookupAttributeMetadata(
259+
schema_name="new_DepartmentId",
260+
display_name=Label(localized_labels=[LocalizedLabel(label="Department", language_code=1033)]),
261+
)
262+
263+
relationship = OneToManyRelationshipMetadata(
264+
schema_name="new_Department_Employee",
265+
referenced_entity="new_department", # Parent table (the "one" side)
266+
referencing_entity="new_employee", # Child table (the "many" side)
267+
referenced_attribute="new_departmentid",
268+
)
269+
270+
result = client.create_one_to_many_relationship(lookup, relationship)
271+
print(f"Created lookup field: {result['lookup_schema_name']}")
272+
273+
# Create a many-to-many relationship: Employee (N) <-> Project (N)
274+
# Employees work on multiple projects; projects have multiple team members
275+
m2m_relationship = ManyToManyRelationshipMetadata(
276+
schema_name="new_employee_project",
277+
entity1_logical_name="new_employee",
278+
entity2_logical_name="new_project",
279+
)
280+
281+
result = client.create_many_to_many_relationship(m2m_relationship)
282+
print(f"Created M:N relationship: {result['relationship_schema_name']}")
283+
284+
# Query relationship metadata
285+
rel = client.get_relationship("new_Department_Employee")
286+
if rel:
287+
print(f"Found: {rel['SchemaName']}")
288+
289+
# Delete a relationship
290+
client.delete_relationship(result['relationship_id'])
291+
```
292+
293+
For simpler scenarios, use the extension helper:
294+
295+
```python
296+
from PowerPlatform.Dataverse.extensions.relationships import create_lookup_field
297+
298+
# Quick way to create a lookup field with sensible defaults
299+
result = create_lookup_field(
300+
client,
301+
referencing_table="contact", # Child table gets the lookup field
302+
lookup_field_name="new_AccountId",
303+
referenced_table="account", # Parent table being referenced
304+
display_name="Account",
305+
)
306+
```
307+
241308
### File operations
242309

243310
```python
@@ -261,7 +328,8 @@ Explore our comprehensive examples in the [`examples/`](https://github.com/micro
261328
- **[Functional Testing](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/basic/functional_testing.py)** - Test core functionality in your environment
262329

263330
**🚀 Advanced Usage:**
264-
- **[Complete Walkthrough](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/walkthrough.py)** - Full feature demonstration with production patterns
331+
- **[Complete Walkthrough](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/walkthrough.py)** - Full feature demonstration with production patterns
332+
- **[Relationship Management](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/relationships.py)** - Create and manage table relationships
265333
- **[File Upload](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/file_upload.py)** - Upload files to Dataverse file columns
266334

267335
📖 See the [examples README](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/README.md) for detailed guidance and learning progression.
@@ -323,8 +391,7 @@ For optimal performance in production environments:
323391
### Limitations
324392

325393
- SQL queries are **read-only** and support a limited subset of SQL syntax
326-
- Create Table supports a limited number of column types. Lookup columns are not yet supported.
327-
- Creating relationships between tables is not yet supported.
394+
- Create Table supports a limited number of column types (string, int, decimal, bool, datetime, picklist)
328395
- File uploads are limited by Dataverse file size restrictions (default 128MB per file)
329396

330397
## Contributing

examples/advanced/relationships.py

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
AssociatedMenuConfiguration,
3131
)
3232
from PowerPlatform.Dataverse.extensions.relationships import create_lookup_field
33-
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError
3433

3534

3635
# Simple logging helper
@@ -95,23 +94,24 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
9594
result = op()
9695
if attempts > 1:
9796
retry_count = attempts - 1
98-
print(
99-
f" * Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total."
100-
)
97+
print(f" * Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total.")
10198
return result
10299
except Exception as ex: # noqa: BLE001
103100
last = ex
104101
continue
105102
if last:
106103
if attempts:
107104
retry_count = max(attempts - 1, 0)
108-
print(
109-
f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total."
110-
)
105+
print(f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total.")
111106
raise last
112107

113108

114109
def main():
110+
# Initialize relationship IDs to None for cleanup safety
111+
rel_id_1 = None
112+
rel_id_2 = None
113+
rel_id_3 = None
114+
115115
print("=" * 80)
116116
print("Dataverse SDK - Relationship Management Example")
117117
print("=" * 80)
@@ -207,11 +207,7 @@ def main():
207207
# Define the lookup attribute metadata
208208
lookup = LookupAttributeMetadata(
209209
schema_name="new_DepartmentId",
210-
display_name=Label(
211-
localized_labels=[
212-
LocalizedLabel(label="Department", language_code=1033)
213-
]
214-
),
210+
display_name=Label(localized_labels=[LocalizedLabel(label="Department", language_code=1033)]),
215211
required_level="None",
216212
)
217213

@@ -229,11 +225,7 @@ def main():
229225
associated_menu_configuration=AssociatedMenuConfiguration(
230226
behavior="UseLabel",
231227
group="Details",
232-
label=Label(
233-
localized_labels=[
234-
LocalizedLabel(label="Employees", language_code=1033)
235-
]
236-
),
228+
label=Label(localized_labels=[LocalizedLabel(label="Employees", language_code=1033)]),
237229
order=10000,
238230
),
239231
)
@@ -250,7 +242,7 @@ def main():
250242
print(f" Lookup field: {result['lookup_schema_name']}")
251243
print(f" Relationship ID: {result['relationship_id']}")
252244

253-
rel_id_1 = result['relationship_id']
245+
rel_id_1 = result["relationship_id"]
254246

255247
# ============================================================================
256248
# 5. CREATE LOOKUP FIELD (Extension Helper)
@@ -279,7 +271,7 @@ def main():
279271
print(f"[OK] Created lookup using helper: {result2['lookup_schema_name']}")
280272
print(f" Relationship: {result2['relationship_schema_name']}")
281273

282-
rel_id_2 = result2['relationship_id']
274+
rel_id_2 = result2["relationship_id"]
283275

284276
# ============================================================================
285277
# 6. CREATE MANY-TO-MANY RELATIONSHIP
@@ -298,20 +290,12 @@ def main():
298290
entity1_associated_menu_configuration=AssociatedMenuConfiguration(
299291
behavior="UseLabel",
300292
group="Details",
301-
label=Label(
302-
localized_labels=[
303-
LocalizedLabel(label="Projects", language_code=1033)
304-
]
305-
),
293+
label=Label(localized_labels=[LocalizedLabel(label="Projects", language_code=1033)]),
306294
),
307295
entity2_associated_menu_configuration=AssociatedMenuConfiguration(
308296
behavior="UseLabel",
309297
group="Details",
310-
label=Label(
311-
localized_labels=[
312-
LocalizedLabel(label="Team Members", language_code=1033)
313-
]
314-
),
298+
label=Label(localized_labels=[LocalizedLabel(label="Team Members", language_code=1033)]),
315299
),
316300
)
317301

@@ -324,7 +308,7 @@ def main():
324308
print(f"[OK] Created M:N relationship: {result3['relationship_schema_name']}")
325309
print(f" Relationship ID: {result3['relationship_id']}")
326310

327-
rel_id_3 = result3['relationship_id']
311+
rel_id_3 = result3["relationship_id"]
328312

329313
# ============================================================================
330314
# 7. QUERY RELATIONSHIP METADATA
@@ -381,7 +365,7 @@ def main():
381365
log_call("Deleting tables")
382366
for table_name in ["new_Employee", "new_Department", "new_Project"]:
383367
try:
384-
backoff(lambda: client.delete_table(table_name))
368+
backoff(lambda name=table_name: client.delete_table(name))
385369
print(f" [OK] Deleted table: {table_name}")
386370
except Exception as e:
387371
print(f" [WARN] Error deleting {table_name}: {e}")
@@ -406,5 +390,6 @@ def main():
406390
except Exception as e:
407391
print(f"\n\nError: {e}")
408392
import traceback
393+
409394
traceback.print_exc()
410395
sys.exit(1)

src/PowerPlatform/Dataverse/client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ def create_one_to_many_relationship(
725725
:raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails.
726726
727727
Example:
728-
Create a one-to-many relationship with full control::
728+
Create a one-to-many relationship: Department (1) -> Employee (N)::
729729
730730
from PowerPlatform.Dataverse.models.metadata import (
731731
LookupAttributeMetadata,
@@ -735,29 +735,28 @@ def create_one_to_many_relationship(
735735
CascadeConfiguration,
736736
)
737737
738-
# Define the lookup attribute
738+
# Define the lookup attribute (added to Employee table)
739739
lookup = LookupAttributeMetadata(
740740
schema_name="new_DepartmentId",
741741
display_name=Label(
742742
localized_labels=[
743743
LocalizedLabel(label="Department", language_code=1033)
744744
]
745745
),
746-
required_level="None"
747746
)
748747
749748
# Define the relationship
750749
relationship = OneToManyRelationshipMetadata(
751750
schema_name="new_Department_Employee",
752-
referenced_entity="new_department",
753-
referencing_entity="new_employee",
751+
referenced_entity="new_department", # Parent table (the "one" side)
752+
referencing_entity="new_employee", # Child table (the "many" side)
754753
referenced_attribute="new_departmentid",
755-
cascade_configuration=CascadeConfiguration(delete="RemoveLink")
754+
cascade_configuration=CascadeConfiguration(
755+
delete="RemoveLink", # When department deleted, unlink employees
756+
),
756757
)
757758
758-
# Create the relationship
759759
result = client.create_one_to_many_relationship(lookup, relationship)
760-
print(f"Created relationship: {result['relationship_schema_name']}")
761760
print(f"Created lookup field: {result['lookup_schema_name']}")
762761
"""
763762
with self._scoped_odata() as od:
@@ -790,12 +789,13 @@ def create_many_to_many_relationship(
790789
:raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails.
791790
792791
Example:
793-
Create a many-to-many relationship::
792+
Create a many-to-many relationship: Employee (N) <-> Project (N)::
794793
795794
from PowerPlatform.Dataverse.models.metadata import (
796795
ManyToManyRelationshipMetadata,
797796
)
798797
798+
# Employees work on multiple projects; projects have multiple team members
799799
relationship = ManyToManyRelationshipMetadata(
800800
schema_name="new_employee_project",
801801
entity1_logical_name="new_employee",

src/PowerPlatform/Dataverse/data/_relationships.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def _get_relationship(self, schema_name: str) -> Optional[Dict[str, Any]]:
139139
:raises HttpError: If the Web API request fails.
140140
"""
141141
url = f"{self.api}/RelationshipDefinitions"
142-
params = {"$filter": f"SchemaName eq '{schema_name}'"}
142+
params = {"$filter": f"SchemaName eq '{self._escape_odata_quotes(schema_name)}'"}
143143
r = self._request("get", url, headers=self._headers(), params=params)
144144
data = r.json()
145145
results = data.get("value", [])

tests/unit/models/test_metadata.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
"""Tests for metadata entity types."""
55

6-
import pytest
76
from PowerPlatform.Dataverse.models.metadata import (
87
LocalizedLabel,
98
Label,

0 commit comments

Comments
 (0)