Skip to content

Commit c2969c9

Browse files
committed
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.
1 parent c1ce5f0 commit c2969c9

File tree

7 files changed

+1110
-6
lines changed

7 files changed

+1110
-6
lines changed

examples/advanced/relationships.py

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

0 commit comments

Comments
 (0)