Skip to content

Commit 26805ce

Browse files
tpellissierclaude
andcommitted
Add relationship metadata API with extension helpers
Implements architectural pattern suggested in PR #12 review feedback: - Core SDK provides low-level operations that mirror .NET SDK (CreateOneToManyRequest) - Operations are named after Dataverse messages, not high-level helper functions - Extension helpers provide convenience wrappers for common scenarios - Uses proper Metadata Entity Types exposed via models Changes: - Add models/metadata.py with Metadata Entity Type classes - LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata - Label, LocalizedLabel, CascadeConfiguration, AssociatedMenuConfiguration - Add relationship operations to data/_odata.py - _create_one_to_many_relationship (POST /RelationshipDefinitions) - _create_many_to_many_relationship - _delete_relationship, _get_relationship - Expose public API in client.py - create_one_to_many_relationship, create_many_to_many_relationship - delete_relationship, get_relationship - Add extensions/relationships.py with helper functions - create_lookup_field (convenience wrapper) - Add examples/advanced/relationships.py demonstrating both approaches This approach aligns with Dataverse's actual API structure and allows users to compose operations using the underlying metadata types, while still providing convenience helpers as opt-in extensions. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c1ce5f0 commit 26805ce

File tree

7 files changed

+1105
-6
lines changed

7 files changed

+1105
-6
lines changed

examples/advanced/relationships.py

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Relationship Management Example for Dataverse SDK.
6+
7+
This example demonstrates:
8+
- Creating one-to-many relationships using the core SDK API
9+
- Creating lookup fields using the convenience extension helper
10+
- Creating many-to-many relationships
11+
- Querying and deleting relationships
12+
- Working with relationship metadata types
13+
14+
Prerequisites:
15+
- pip install PowerPlatform-Dataverse-Client
16+
- pip install azure-identity
17+
"""
18+
19+
import sys
20+
import time
21+
from azure.identity import InteractiveBrowserCredential
22+
from PowerPlatform.Dataverse.client import DataverseClient
23+
from PowerPlatform.Dataverse.models.metadata import (
24+
LookupAttributeMetadata,
25+
OneToManyRelationshipMetadata,
26+
ManyToManyRelationshipMetadata,
27+
Label,
28+
LocalizedLabel,
29+
CascadeConfiguration,
30+
AssociatedMenuConfiguration,
31+
)
32+
from PowerPlatform.Dataverse.extensions.relationships import create_lookup_field
33+
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError
34+
35+
36+
# Simple logging helper
37+
def log_call(description):
38+
print(f"\n{description}")
39+
40+
41+
def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
42+
"""Retry helper with exponential backoff."""
43+
last = None
44+
total_delay = 0
45+
attempts = 0
46+
for d in delays:
47+
if d:
48+
time.sleep(d)
49+
total_delay += d
50+
attempts += 1
51+
try:
52+
result = op()
53+
if attempts > 1:
54+
retry_count = attempts - 1
55+
print(
56+
f" ↺ Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total."
57+
)
58+
return result
59+
except Exception as ex: # noqa: BLE001
60+
last = ex
61+
continue
62+
if last:
63+
if attempts:
64+
retry_count = max(attempts - 1, 0)
65+
print(
66+
f" ⚠ Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total."
67+
)
68+
raise last
69+
70+
71+
def main():
72+
print("=" * 80)
73+
print("Dataverse SDK - Relationship Management Example")
74+
print("=" * 80)
75+
76+
# ============================================================================
77+
# 1. SETUP & AUTHENTICATION
78+
# ============================================================================
79+
print("\n" + "=" * 80)
80+
print("1. Setup & Authentication")
81+
print("=" * 80)
82+
83+
base_url = input("Enter Dataverse org URL (e.g. https://yourorg.crm.dynamics.com): ").strip()
84+
if not base_url:
85+
print("No URL entered; exiting.")
86+
sys.exit(1)
87+
88+
base_url = base_url.rstrip("/")
89+
90+
log_call("InteractiveBrowserCredential()")
91+
credential = InteractiveBrowserCredential()
92+
93+
log_call(f"DataverseClient(base_url='{base_url}', credential=...)")
94+
client = DataverseClient(base_url=base_url, credential=credential)
95+
print(f"✓ Connected to: {base_url}")
96+
97+
# ============================================================================
98+
# 2. CREATE SAMPLE TABLES
99+
# ============================================================================
100+
print("\n" + "=" * 80)
101+
print("2. Create Sample Tables")
102+
print("=" * 80)
103+
104+
# Create a parent table (Department)
105+
log_call("Creating 'new_Department' table")
106+
try:
107+
backoff(lambda: client.delete_table("new_Department"))
108+
print(" (Cleaned up existing table)")
109+
except (HttpError, MetadataError):
110+
pass
111+
112+
dept_table = backoff(
113+
lambda: client.create_table(
114+
"new_Department",
115+
{
116+
"new_DepartmentCode": "string",
117+
"new_Budget": "decimal",
118+
},
119+
)
120+
)
121+
print(f"✓ Created table: {dept_table['table_schema_name']}")
122+
123+
# Create a child table (Employee)
124+
log_call("Creating 'new_Employee' table")
125+
try:
126+
backoff(lambda: client.delete_table("new_Employee"))
127+
print(" (Cleaned up existing table)")
128+
except (HttpError, MetadataError):
129+
pass
130+
131+
emp_table = backoff(
132+
lambda: client.create_table(
133+
"new_Employee",
134+
{
135+
"new_EmployeeNumber": "string",
136+
"new_Salary": "decimal",
137+
},
138+
)
139+
)
140+
print(f"✓ Created table: {emp_table['table_schema_name']}")
141+
142+
# Create a project table for many-to-many example
143+
log_call("Creating 'new_Project' table")
144+
try:
145+
backoff(lambda: client.delete_table("new_Project"))
146+
print(" (Cleaned up existing table)")
147+
except (HttpError, MetadataError):
148+
pass
149+
150+
proj_table = backoff(
151+
lambda: client.create_table(
152+
"new_Project",
153+
{
154+
"new_ProjectCode": "string",
155+
"new_StartDate": "datetime",
156+
},
157+
)
158+
)
159+
print(f"✓ Created table: {proj_table['table_schema_name']}")
160+
161+
# ============================================================================
162+
# 3. CREATE ONE-TO-MANY RELATIONSHIP (Core SDK API)
163+
# ============================================================================
164+
print("\n" + "=" * 80)
165+
print("3. Create One-to-Many Relationship (Core API)")
166+
print("=" * 80)
167+
168+
log_call("Creating lookup field on Employee referencing Department")
169+
170+
# Define the lookup attribute metadata
171+
lookup = LookupAttributeMetadata(
172+
schema_name="new_DepartmentId",
173+
display_name=Label(
174+
localized_labels=[
175+
LocalizedLabel(label="Department", language_code=1033)
176+
]
177+
),
178+
required_level="None",
179+
)
180+
181+
# Define the relationship metadata
182+
relationship = OneToManyRelationshipMetadata(
183+
schema_name="new_Department_Employee",
184+
referenced_entity=dept_table["table_logical_name"],
185+
referencing_entity=emp_table["table_logical_name"],
186+
referenced_attribute=f"{dept_table['table_logical_name']}id",
187+
cascade_configuration=CascadeConfiguration(
188+
delete="RemoveLink", # When department is deleted, remove the link but keep employees
189+
assign="NoCascade",
190+
merge="NoCascade",
191+
),
192+
associated_menu_configuration=AssociatedMenuConfiguration(
193+
behavior="UseLabel",
194+
group="Details",
195+
label=Label(
196+
localized_labels=[
197+
LocalizedLabel(label="Employees", language_code=1033)
198+
]
199+
),
200+
order=10000,
201+
),
202+
)
203+
204+
# Create the relationship
205+
result = backoff(
206+
lambda: client.create_one_to_many_relationship(
207+
lookup=lookup,
208+
relationship=relationship,
209+
)
210+
)
211+
212+
print(f"✓ Created relationship: {result['relationship_schema_name']}")
213+
print(f" Lookup field: {result['lookup_schema_name']}")
214+
print(f" Relationship ID: {result['relationship_id']}")
215+
216+
rel_id_1 = result['relationship_id']
217+
218+
# ============================================================================
219+
# 4. CREATE LOOKUP FIELD (Extension Helper)
220+
# ============================================================================
221+
print("\n" + "=" * 80)
222+
print("4. Create Lookup Field (Extension Helper)")
223+
print("=" * 80)
224+
225+
log_call("Creating lookup field on Employee referencing Account (using helper)")
226+
227+
# Use the convenience helper for simpler scenarios
228+
result2 = backoff(
229+
lambda: create_lookup_field(
230+
client,
231+
referencing_table=emp_table["table_logical_name"],
232+
lookup_field_name="new_AccountId",
233+
referenced_table="account", # Standard Dataverse table
234+
display_name="Company Account",
235+
description="The account/company this employee works for",
236+
required=False,
237+
cascade_delete="RemoveLink",
238+
)
239+
)
240+
241+
print(f"✓ Created lookup using helper: {result2['lookup_schema_name']}")
242+
print(f" Relationship: {result2['relationship_schema_name']}")
243+
244+
rel_id_2 = result2['relationship_id']
245+
246+
# ============================================================================
247+
# 5. CREATE MANY-TO-MANY RELATIONSHIP
248+
# ============================================================================
249+
print("\n" + "=" * 80)
250+
print("5. Create Many-to-Many Relationship")
251+
print("=" * 80)
252+
253+
log_call("Creating M:N relationship between Employee and Project")
254+
255+
# Define many-to-many relationship
256+
m2m_relationship = ManyToManyRelationshipMetadata(
257+
schema_name="new_employee_project",
258+
entity1_logical_name=emp_table["table_logical_name"],
259+
entity2_logical_name=proj_table["table_logical_name"],
260+
entity1_associated_menu_configuration=AssociatedMenuConfiguration(
261+
behavior="UseLabel",
262+
group="Details",
263+
label=Label(
264+
localized_labels=[
265+
LocalizedLabel(label="Projects", language_code=1033)
266+
]
267+
),
268+
),
269+
entity2_associated_menu_configuration=AssociatedMenuConfiguration(
270+
behavior="UseLabel",
271+
group="Details",
272+
label=Label(
273+
localized_labels=[
274+
LocalizedLabel(label="Team Members", language_code=1033)
275+
]
276+
),
277+
),
278+
)
279+
280+
result3 = backoff(
281+
lambda: client.create_many_to_many_relationship(
282+
relationship=m2m_relationship,
283+
)
284+
)
285+
286+
print(f"✓ Created M:N relationship: {result3['relationship_schema_name']}")
287+
print(f" Relationship ID: {result3['relationship_id']}")
288+
289+
rel_id_3 = result3['relationship_id']
290+
291+
# ============================================================================
292+
# 6. QUERY RELATIONSHIP METADATA
293+
# ============================================================================
294+
print("\n" + "=" * 80)
295+
print("6. Query Relationship Metadata")
296+
print("=" * 80)
297+
298+
log_call("Retrieving relationship by schema name")
299+
300+
rel_metadata = client.get_relationship("new_Department_Employee")
301+
if rel_metadata:
302+
print(f"✓ Found relationship: {rel_metadata.get('SchemaName')}")
303+
print(f" Type: {rel_metadata.get('@odata.type')}")
304+
print(f" Referenced Entity: {rel_metadata.get('ReferencedEntity')}")
305+
print(f" Referencing Entity: {rel_metadata.get('ReferencingEntity')}")
306+
else:
307+
print(" Relationship not found")
308+
309+
# ============================================================================
310+
# 7. CLEANUP
311+
# ============================================================================
312+
print("\n" + "=" * 80)
313+
print("7. Cleanup")
314+
print("=" * 80)
315+
316+
cleanup = input("\nDelete created relationships and tables? (y/n): ").strip().lower()
317+
318+
if cleanup == "y":
319+
# Delete relationships first (required before deleting tables)
320+
log_call("Deleting relationships")
321+
try:
322+
if rel_id_1:
323+
backoff(lambda: client.delete_relationship(rel_id_1))
324+
print(f" ✓ Deleted relationship: new_Department_Employee")
325+
except Exception as e:
326+
print(f" ⚠ Error deleting relationship 1: {e}")
327+
328+
try:
329+
if rel_id_2:
330+
backoff(lambda: client.delete_relationship(rel_id_2))
331+
print(f" ✓ Deleted relationship: account->employee")
332+
except Exception as e:
333+
print(f" ⚠ Error deleting relationship 2: {e}")
334+
335+
try:
336+
if rel_id_3:
337+
backoff(lambda: client.delete_relationship(rel_id_3))
338+
print(f" ✓ Deleted relationship: new_employee_project")
339+
except Exception as e:
340+
print(f" ⚠ Error deleting relationship 3: {e}")
341+
342+
# Delete tables
343+
log_call("Deleting tables")
344+
for table_name in ["new_Employee", "new_Department", "new_Project"]:
345+
try:
346+
backoff(lambda: client.delete_table(table_name))
347+
print(f" ✓ Deleted table: {table_name}")
348+
except Exception as e:
349+
print(f" ⚠ Error deleting {table_name}: {e}")
350+
351+
print("\n✓ Cleanup complete")
352+
else:
353+
print("\nSkipping cleanup. Remember to manually delete:")
354+
print(" - Relationships: new_Department_Employee, account->employee, new_employee_project")
355+
print(" - Tables: new_Employee, new_Department, new_Project")
356+
357+
print("\n" + "=" * 80)
358+
print("Example Complete!")
359+
print("=" * 80)
360+
361+
362+
if __name__ == "__main__":
363+
try:
364+
main()
365+
except KeyboardInterrupt:
366+
print("\n\nExample interrupted by user.")
367+
sys.exit(1)
368+
except Exception as e:
369+
print(f"\n\nError: {e}")
370+
import traceback
371+
traceback.print_exc()
372+
sys.exit(1)

0 commit comments

Comments
 (0)