3232# Import SDK components (assumes installation is already validated)
3333from PowerPlatform .Dataverse .client import DataverseClient
3434from PowerPlatform .Dataverse .core .errors import HttpError , MetadataError
35+ from PowerPlatform .Dataverse .models .relationship import (
36+ LookupAttributeMetadata ,
37+ OneToManyRelationshipMetadata ,
38+ ManyToManyRelationshipMetadata ,
39+ CascadeConfiguration ,
40+ )
41+ from PowerPlatform .Dataverse .models .labels import Label , LocalizedLabel
42+ from PowerPlatform .Dataverse .common .constants import (
43+ CASCADE_BEHAVIOR_NO_CASCADE ,
44+ CASCADE_BEHAVIOR_REMOVE_LINK ,
45+ )
3546from azure .identity import InteractiveBrowserCredential
3647
3748
@@ -380,6 +391,246 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor
380391 print ("Test table kept for future testing" )
381392
382393
394+ def backoff (op , * , delays = (0 , 2 , 5 , 10 , 20 , 20 )):
395+ """Retry helper with exponential backoff for metadata propagation delays."""
396+ last = None
397+ total_delay = 0
398+ attempts = 0
399+ for d in delays :
400+ if d :
401+ time .sleep (d )
402+ total_delay += d
403+ attempts += 1
404+ try :
405+ result = op ()
406+ if attempts > 1 :
407+ print (f" * Backoff succeeded after { attempts - 1 } retry(s); waited { total_delay } s total." )
408+ return result
409+ except Exception as ex :
410+ last = ex
411+ continue
412+ if last :
413+ if attempts :
414+ print (f" [WARN] Backoff exhausted after { max (attempts - 1 , 0 )} retry(s); waited { total_delay } s total." )
415+ raise last
416+
417+
418+ def test_relationships (client : DataverseClient ) -> None :
419+ """Test relationship lifecycle: create tables, 1:N, N:N, query, delete."""
420+ print ("\n -> Relationship Tests" )
421+ print ("=" * 50 )
422+
423+ rel_parent_schema = "test_RelParent"
424+ rel_child_schema = "test_RelChild"
425+ rel_m2m_schema = "test_RelProject"
426+
427+ # Track IDs for cleanup
428+ rel_id_1n = None
429+ rel_id_lookup = None
430+ rel_id_nn = None
431+ created_tables = []
432+
433+ try :
434+ # --- Cleanup any leftover resources from previous run ---
435+ print ("Cleaning up previous relationship test resources..." )
436+ for rel_name in [
437+ "test_RelParent_RelChild" ,
438+ "contact_test_relchild_test_ManagerId" ,
439+ "test_relchild_relproject" ,
440+ ]:
441+ try :
442+ rel = client .tables .get_relationship (rel_name )
443+ if rel :
444+ client .tables .delete_relationship (rel .relationship_id )
445+ print (f" (Cleaned up relationship: { rel_name } )" )
446+ except Exception :
447+ pass
448+
449+ for tbl in [rel_child_schema , rel_parent_schema , rel_m2m_schema ]:
450+ try :
451+ if client .tables .get (tbl ):
452+ client .tables .delete (tbl )
453+ print (f" (Cleaned up table: { tbl } )" )
454+ except Exception :
455+ pass
456+
457+ # --- Create parent and child tables ---
458+ print ("\n Creating relationship test tables..." )
459+
460+ parent_info = backoff (
461+ lambda : client .tables .create (
462+ rel_parent_schema ,
463+ {"test_Code" : "string" },
464+ )
465+ )
466+ created_tables .append (rel_parent_schema )
467+ print (f"[OK] Created parent table: { parent_info ['table_schema_name' ]} " )
468+
469+ child_info = backoff (
470+ lambda : client .tables .create (
471+ rel_child_schema ,
472+ {"test_Number" : "string" },
473+ )
474+ )
475+ created_tables .append (rel_child_schema )
476+ print (f"[OK] Created child table: { child_info ['table_schema_name' ]} " )
477+
478+ proj_info = backoff (
479+ lambda : client .tables .create (
480+ rel_m2m_schema ,
481+ {"test_ProjectCode" : "string" },
482+ )
483+ )
484+ created_tables .append (rel_m2m_schema )
485+ print (f"[OK] Created M:N table: { proj_info ['table_schema_name' ]} " )
486+
487+ # --- Wait for table metadata to propagate ---
488+ wait_for_table_metadata (client , rel_parent_schema )
489+ wait_for_table_metadata (client , rel_child_schema )
490+ wait_for_table_metadata (client , rel_m2m_schema )
491+
492+ # --- Test 1: Create 1:N relationship (core API) ---
493+ print ("\n Test 1: Create 1:N relationship (core API)" )
494+ print (" " + "-" * 45 )
495+
496+ lookup = LookupAttributeMetadata (
497+ schema_name = "test_ParentId" ,
498+ display_name = Label (localized_labels = [LocalizedLabel (label = "Parent" , language_code = 1033 )]),
499+ required_level = "None" ,
500+ )
501+
502+ relationship = OneToManyRelationshipMetadata (
503+ schema_name = "test_RelParent_RelChild" ,
504+ referenced_entity = parent_info ["table_logical_name" ],
505+ referencing_entity = child_info ["table_logical_name" ],
506+ referenced_attribute = f"{ parent_info ['table_logical_name' ]} id" ,
507+ cascade_configuration = CascadeConfiguration (
508+ delete = CASCADE_BEHAVIOR_REMOVE_LINK ,
509+ assign = CASCADE_BEHAVIOR_NO_CASCADE ,
510+ merge = CASCADE_BEHAVIOR_NO_CASCADE ,
511+ ),
512+ )
513+
514+ result_1n = backoff (
515+ lambda : client .tables .create_one_to_many_relationship (
516+ lookup = lookup ,
517+ relationship = relationship ,
518+ )
519+ )
520+
521+ assert result_1n .relationship_schema_name == "test_RelParent_RelChild"
522+ assert result_1n .relationship_type == "one_to_many"
523+ assert result_1n .lookup_schema_name is not None
524+ rel_id_1n = result_1n .relationship_id
525+ print (f" [OK] Created 1:N relationship: { result_1n .relationship_schema_name } " )
526+ print (f" Lookup: { result_1n .lookup_schema_name } " )
527+ print (f" ID: { rel_id_1n } " )
528+
529+ # --- Test 2: Create lookup field (convenience API) ---
530+ print ("\n Test 2: Create lookup field (convenience API)" )
531+ print (" " + "-" * 45 )
532+
533+ result_lookup = backoff (
534+ lambda : client .tables .create_lookup_field (
535+ referencing_table = child_info ["table_logical_name" ],
536+ lookup_field_name = "test_ManagerId" ,
537+ referenced_table = "contact" ,
538+ display_name = "Manager" ,
539+ description = "The record's manager contact" ,
540+ required = False ,
541+ cascade_delete = CASCADE_BEHAVIOR_REMOVE_LINK ,
542+ )
543+ )
544+
545+ assert result_lookup .relationship_type == "one_to_many"
546+ assert result_lookup .lookup_schema_name is not None
547+ rel_id_lookup = result_lookup .relationship_id
548+ print (f" [OK] Created lookup: { result_lookup .lookup_schema_name } " )
549+ print (f" Relationship: { result_lookup .relationship_schema_name } " )
550+
551+ # --- Test 3: Create N:N relationship ---
552+ print ("\n Test 3: Create N:N relationship" )
553+ print (" " + "-" * 45 )
554+
555+ m2m = ManyToManyRelationshipMetadata (
556+ schema_name = "test_relchild_relproject" ,
557+ entity1_logical_name = child_info ["table_logical_name" ],
558+ entity2_logical_name = proj_info ["table_logical_name" ],
559+ )
560+
561+ result_nn = backoff (lambda : client .tables .create_many_to_many_relationship (relationship = m2m ))
562+
563+ assert result_nn .relationship_schema_name == "test_relchild_relproject"
564+ assert result_nn .relationship_type == "many_to_many"
565+ rel_id_nn = result_nn .relationship_id
566+ print (f" [OK] Created N:N relationship: { result_nn .relationship_schema_name } " )
567+ print (f" ID: { rel_id_nn } " )
568+
569+ # --- Test 4: Get relationship metadata ---
570+ print ("\n Test 4: Query relationship metadata" )
571+ print (" " + "-" * 45 )
572+
573+ fetched_1n = client .tables .get_relationship ("test_RelParent_RelChild" )
574+ assert fetched_1n is not None
575+ assert fetched_1n .relationship_type == "one_to_many"
576+ assert fetched_1n .relationship_id == rel_id_1n
577+ print (f" [OK] Retrieved 1:N: { fetched_1n .relationship_schema_name } " )
578+ print (f" Referenced: { fetched_1n .referenced_entity } " )
579+ print (f" Referencing: { fetched_1n .referencing_entity } " )
580+
581+ fetched_nn = client .tables .get_relationship ("test_relchild_relproject" )
582+ assert fetched_nn is not None
583+ assert fetched_nn .relationship_type == "many_to_many"
584+ assert fetched_nn .relationship_id == rel_id_nn
585+ print (f" [OK] Retrieved N:N: { fetched_nn .relationship_schema_name } " )
586+ print (f" Entity1: { fetched_nn .entity1_logical_name } " )
587+ print (f" Entity2: { fetched_nn .entity2_logical_name } " )
588+
589+ # Non-existent relationship should return None
590+ missing = client .tables .get_relationship ("nonexistent_relationship_xyz" )
591+ assert missing is None
592+ print (" [OK] Non-existent relationship returns None" )
593+
594+ # --- Test 5: Delete relationships ---
595+ print ("\n Test 5: Delete relationships" )
596+ print (" " + "-" * 45 )
597+
598+ backoff (lambda : client .tables .delete_relationship (rel_id_1n ))
599+ rel_id_1n = None
600+ print (" [OK] Deleted 1:N relationship" )
601+
602+ backoff (lambda : client .tables .delete_relationship (rel_id_lookup ))
603+ rel_id_lookup = None
604+ print (" [OK] Deleted lookup relationship" )
605+
606+ backoff (lambda : client .tables .delete_relationship (rel_id_nn ))
607+ rel_id_nn = None
608+ print (" [OK] Deleted N:N relationship" )
609+
610+ # Verify deletion
611+ verify = client .tables .get_relationship ("test_RelParent_RelChild" )
612+ assert verify is None
613+ print (" [OK] Verified 1:N deletion (get returns None)" )
614+
615+ print ("\n [OK] All relationship tests passed!" )
616+
617+ finally :
618+ # Cleanup: delete any remaining relationships then tables
619+ for rid in [rel_id_1n , rel_id_lookup , rel_id_nn ]:
620+ if rid :
621+ try :
622+ client .tables .delete_relationship (rid )
623+ except Exception :
624+ pass
625+
626+ for tbl in reversed (created_tables ):
627+ try :
628+ backoff (lambda name = tbl : client .tables .delete (name ))
629+ print (f" (Cleaned up table: { tbl } )" )
630+ except Exception as e :
631+ print (f" [WARN] Could not delete { tbl } : { e } " )
632+
633+
383634def _table_still_exists (client : DataverseClient , table_schema_name : Optional [str ]) -> bool :
384635 if not table_schema_name :
385636 return False
@@ -403,6 +654,7 @@ def main():
403654 print (" - Table Creation & Metadata Operations" )
404655 print (" - Record CRUD Operations" )
405656 print (" - Query Functionality" )
657+ print (" - Relationship Operations (1:N, N:N, lookup, get, delete)" )
406658 print (" - Interactive Cleanup" )
407659 print ("=" * 70 )
408660 print ("For installation validation, run examples/basic/installation_example.py first" )
@@ -422,6 +674,9 @@ def main():
422674 # Test querying
423675 test_query_records (client , table_info )
424676
677+ # Test relationships
678+ test_relationships (client )
679+
425680 # Success summary
426681 print ("\n Functional Test Summary" )
427682 print ("=" * 50 )
@@ -430,6 +685,7 @@ def main():
430685 print ("[OK] Record Creation: Success" )
431686 print ("[OK] Record Reading: Success" )
432687 print ("[OK] Record Querying: Success" )
688+ print ("[OK] Relationship Operations: Success" )
433689 print ("\n Your PowerPlatform Dataverse Client SDK is fully functional!" )
434690
435691 # Cleanup
0 commit comments