3434from PowerPlatform .Dataverse .client import DataverseClient
3535from PowerPlatform .Dataverse .core .errors import HttpError , MetadataError
3636from PowerPlatform .Dataverse .models .upsert import UpsertItem
37+ from PowerPlatform .Dataverse .models .relationship import (
38+ LookupAttributeMetadata ,
39+ OneToManyRelationshipMetadata ,
40+ ManyToManyRelationshipMetadata ,
41+ CascadeConfiguration ,
42+ )
43+ from PowerPlatform .Dataverse .models .labels import Label , LocalizedLabel
44+ from PowerPlatform .Dataverse .common .constants import (
45+ CASCADE_BEHAVIOR_NO_CASCADE ,
46+ CASCADE_BEHAVIOR_REMOVE_LINK ,
47+ )
3748from azure .identity import InteractiveBrowserCredential
3849
3950
@@ -849,6 +860,274 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor
849860 print ("Test table kept for future testing" )
850861
851862
863+ def backoff (op , * , delays = (0 , 2 , 5 , 10 , 20 , 20 )):
864+ """Retry helper with exponential backoff for metadata propagation delays."""
865+ last = None
866+ total_delay = 0
867+ attempts = 0
868+ for d in delays :
869+ if d :
870+ time .sleep (d )
871+ total_delay += d
872+ attempts += 1
873+ try :
874+ result = op ()
875+ if attempts > 1 :
876+ print (f" * Backoff succeeded after { attempts - 1 } retry(s); waited { total_delay } s total." )
877+ return result
878+ except Exception as ex :
879+ last = ex
880+ continue
881+ if last :
882+ if attempts :
883+ print (f" [WARN] Backoff exhausted after { max (attempts - 1 , 0 )} retry(s); waited { total_delay } s total." )
884+ raise last
885+
886+
887+ def test_relationships (client : DataverseClient ) -> None :
888+ """Test relationship lifecycle: create tables, 1:N, N:N, query, delete."""
889+ print ("\n -> Relationship Tests" )
890+ print ("=" * 50 )
891+
892+ rel_parent_schema = "test_RelParent"
893+ rel_child_schema = "test_RelChild"
894+ rel_m2m_schema = "test_RelProject"
895+
896+ # Track IDs for cleanup
897+ rel_id_1n = None
898+ rel_id_lookup = None
899+ rel_id_nn = None
900+ created_tables = []
901+
902+ try :
903+ # --- Cleanup any leftover resources from previous run ---
904+ print ("Checking for leftover relationship test resources..." )
905+ found_leftovers = False
906+ for rel_name in [
907+ "test_RelParent_RelChild" ,
908+ "contact_test_relchild_test_ManagerId" ,
909+ "test_relchild_relproject" ,
910+ ]:
911+ try :
912+ rel = client .tables .get_relationship (rel_name )
913+ if rel :
914+ found_leftovers = True
915+ break
916+ except Exception :
917+ pass
918+
919+ if not found_leftovers :
920+ for tbl in [rel_child_schema , rel_parent_schema , rel_m2m_schema ]:
921+ try :
922+ if client .tables .get (tbl ):
923+ found_leftovers = True
924+ break
925+ except Exception :
926+ pass
927+
928+ if found_leftovers :
929+ cleanup_ok = input ("Found leftover test resources. Clean up? (y/N): " ).strip ().lower () in ["y" , "yes" ]
930+ if cleanup_ok :
931+ for rel_name in [
932+ "test_RelParent_RelChild" ,
933+ "contact_test_relchild_test_ManagerId" ,
934+ "test_relchild_relproject" ,
935+ ]:
936+ try :
937+ rel = client .tables .get_relationship (rel_name )
938+ if rel :
939+ client .tables .delete_relationship (rel .relationship_id )
940+ print (f" (Cleaned up relationship: { rel_name } )" )
941+ except Exception :
942+ pass
943+
944+ for tbl in [rel_child_schema , rel_parent_schema , rel_m2m_schema ]:
945+ try :
946+ if client .tables .get (tbl ):
947+ client .tables .delete (tbl )
948+ print (f" (Cleaned up table: { tbl } )" )
949+ except Exception :
950+ pass
951+ else :
952+ print ("Skipping cleanup -- resources may conflict with new test run." )
953+
954+ # --- Create parent and child tables ---
955+ print ("\n Creating relationship test tables..." )
956+
957+ parent_info = backoff (
958+ lambda : client .tables .create (
959+ rel_parent_schema ,
960+ {"test_Code" : "string" },
961+ )
962+ )
963+ created_tables .append (rel_parent_schema )
964+ print (f"[OK] Created parent table: { parent_info ['table_schema_name' ]} " )
965+
966+ child_info = backoff (
967+ lambda : client .tables .create (
968+ rel_child_schema ,
969+ {"test_Number" : "string" },
970+ )
971+ )
972+ created_tables .append (rel_child_schema )
973+ print (f"[OK] Created child table: { child_info ['table_schema_name' ]} " )
974+
975+ proj_info = backoff (
976+ lambda : client .tables .create (
977+ rel_m2m_schema ,
978+ {"test_ProjectCode" : "string" },
979+ )
980+ )
981+ created_tables .append (rel_m2m_schema )
982+ print (f"[OK] Created M:N table: { proj_info ['table_schema_name' ]} " )
983+
984+ # --- Wait for table metadata to propagate ---
985+ wait_for_table_metadata (client , rel_parent_schema )
986+ wait_for_table_metadata (client , rel_child_schema )
987+ wait_for_table_metadata (client , rel_m2m_schema )
988+
989+ # --- Test 1: Create 1:N relationship (core API) ---
990+ print ("\n Test 1: Create 1:N relationship (core API)" )
991+ print (" " + "-" * 45 )
992+
993+ lookup = LookupAttributeMetadata (
994+ schema_name = "test_ParentId" ,
995+ display_name = Label (localized_labels = [LocalizedLabel (label = "Parent" , language_code = 1033 )]),
996+ required_level = "None" ,
997+ )
998+
999+ relationship = OneToManyRelationshipMetadata (
1000+ schema_name = "test_RelParent_RelChild" ,
1001+ referenced_entity = parent_info ["table_logical_name" ],
1002+ referencing_entity = child_info ["table_logical_name" ],
1003+ referenced_attribute = f"{ parent_info ['table_logical_name' ]} id" ,
1004+ cascade_configuration = CascadeConfiguration (
1005+ delete = CASCADE_BEHAVIOR_REMOVE_LINK ,
1006+ assign = CASCADE_BEHAVIOR_NO_CASCADE ,
1007+ merge = CASCADE_BEHAVIOR_NO_CASCADE ,
1008+ ),
1009+ )
1010+
1011+ result_1n = backoff (
1012+ lambda : client .tables .create_one_to_many_relationship (
1013+ lookup = lookup ,
1014+ relationship = relationship ,
1015+ )
1016+ )
1017+
1018+ assert result_1n .relationship_schema_name == "test_RelParent_RelChild"
1019+ assert result_1n .relationship_type == "one_to_many"
1020+ assert result_1n .lookup_schema_name is not None
1021+ rel_id_1n = result_1n .relationship_id
1022+ print (f" [OK] Created 1:N relationship: { result_1n .relationship_schema_name } " )
1023+ print (f" Lookup: { result_1n .lookup_schema_name } " )
1024+ print (f" ID: { rel_id_1n } " )
1025+
1026+ # --- Test 2: Create lookup field (convenience API) ---
1027+ print ("\n Test 2: Create lookup field (convenience API)" )
1028+ print (" " + "-" * 45 )
1029+
1030+ result_lookup = backoff (
1031+ lambda : client .tables .create_lookup_field (
1032+ referencing_table = child_info ["table_logical_name" ],
1033+ lookup_field_name = "test_ManagerId" ,
1034+ referenced_table = "contact" ,
1035+ display_name = "Manager" ,
1036+ description = "The record's manager contact" ,
1037+ required = False ,
1038+ cascade_delete = CASCADE_BEHAVIOR_REMOVE_LINK ,
1039+ )
1040+ )
1041+
1042+ assert result_lookup .relationship_type == "one_to_many"
1043+ assert result_lookup .lookup_schema_name is not None
1044+ rel_id_lookup = result_lookup .relationship_id
1045+ print (f" [OK] Created lookup: { result_lookup .lookup_schema_name } " )
1046+ print (f" Relationship: { result_lookup .relationship_schema_name } " )
1047+
1048+ # --- Test 3: Create N:N relationship ---
1049+ print ("\n Test 3: Create N:N relationship" )
1050+ print (" " + "-" * 45 )
1051+
1052+ m2m = ManyToManyRelationshipMetadata (
1053+ schema_name = "test_relchild_relproject" ,
1054+ entity1_logical_name = child_info ["table_logical_name" ],
1055+ entity2_logical_name = proj_info ["table_logical_name" ],
1056+ )
1057+
1058+ result_nn = backoff (lambda : client .tables .create_many_to_many_relationship (relationship = m2m ))
1059+
1060+ assert result_nn .relationship_schema_name == "test_relchild_relproject"
1061+ assert result_nn .relationship_type == "many_to_many"
1062+ rel_id_nn = result_nn .relationship_id
1063+ print (f" [OK] Created N:N relationship: { result_nn .relationship_schema_name } " )
1064+ print (f" ID: { rel_id_nn } " )
1065+
1066+ # --- Test 4: Get relationship metadata ---
1067+ print ("\n Test 4: Query relationship metadata" )
1068+ print (" " + "-" * 45 )
1069+
1070+ fetched_1n = client .tables .get_relationship ("test_RelParent_RelChild" )
1071+ assert fetched_1n is not None
1072+ assert fetched_1n .relationship_type == "one_to_many"
1073+ assert fetched_1n .relationship_id == rel_id_1n
1074+ print (f" [OK] Retrieved 1:N: { fetched_1n .relationship_schema_name } " )
1075+ print (f" Referenced: { fetched_1n .referenced_entity } " )
1076+ print (f" Referencing: { fetched_1n .referencing_entity } " )
1077+
1078+ fetched_nn = client .tables .get_relationship ("test_relchild_relproject" )
1079+ assert fetched_nn is not None
1080+ assert fetched_nn .relationship_type == "many_to_many"
1081+ assert fetched_nn .relationship_id == rel_id_nn
1082+ print (f" [OK] Retrieved N:N: { fetched_nn .relationship_schema_name } " )
1083+ print (f" Entity1: { fetched_nn .entity1_logical_name } " )
1084+ print (f" Entity2: { fetched_nn .entity2_logical_name } " )
1085+
1086+ # Non-existent relationship should return None
1087+ missing = client .tables .get_relationship ("nonexistent_relationship_xyz" )
1088+ assert missing is None
1089+ print (" [OK] Non-existent relationship returns None" )
1090+
1091+ # --- Test 5: Delete relationships ---
1092+ print ("\n Test 5: Delete relationships" )
1093+ print (" " + "-" * 45 )
1094+
1095+ backoff (lambda : client .tables .delete_relationship (rel_id_1n ))
1096+ rel_id_1n = None
1097+ print (" [OK] Deleted 1:N relationship" )
1098+
1099+ backoff (lambda : client .tables .delete_relationship (rel_id_lookup ))
1100+ rel_id_lookup = None
1101+ print (" [OK] Deleted lookup relationship" )
1102+
1103+ backoff (lambda : client .tables .delete_relationship (rel_id_nn ))
1104+ rel_id_nn = None
1105+ print (" [OK] Deleted N:N relationship" )
1106+
1107+ # Verify deletion
1108+ verify = client .tables .get_relationship ("test_RelParent_RelChild" )
1109+ assert verify is None
1110+ print (" [OK] Verified 1:N deletion (get returns None)" )
1111+
1112+ print ("\n [OK] All relationship tests passed!" )
1113+
1114+ finally :
1115+ # Cleanup: delete any remaining relationships then tables
1116+ for rid in [rel_id_1n , rel_id_lookup , rel_id_nn ]:
1117+ if rid :
1118+ try :
1119+ client .tables .delete_relationship (rid )
1120+ except Exception :
1121+ pass
1122+
1123+ for tbl in reversed (created_tables ):
1124+ try :
1125+ backoff (lambda name = tbl : client .tables .delete (name ))
1126+ print (f" (Cleaned up table: { tbl } )" )
1127+ except Exception as e :
1128+ print (f" [WARN] Could not delete { tbl } : { e } " )
1129+
1130+
8521131def _table_still_exists (client : DataverseClient , table_schema_name : Optional [str ]) -> bool :
8531132 if not table_schema_name :
8541133 return False
@@ -873,6 +1152,7 @@ def main():
8731152 print (" - Record CRUD Operations" )
8741153 print (" - Query Functionality" )
8751154 print (" - Batch Operations (create, read, update, changeset, delete)" )
1155+ print (" - Relationship Operations (1:N, N:N, lookup, get, delete)" )
8761156 print (" - Interactive Cleanup" )
8771157 print ("=" * 70 )
8781158 print ("For installation validation, run examples/basic/installation_example.py first" )
@@ -898,6 +1178,9 @@ def main():
8981178 # Test batch operations (all operation types)
8991179 test_batch_all_operations (client , table_info )
9001180
1181+ # Test relationships
1182+ test_relationships (client )
1183+
9011184 # Success summary
9021185 print ("\n Functional Test Summary" )
9031186 print ("=" * 50 )
@@ -908,6 +1191,7 @@ def main():
9081191 print ("[OK] Record Querying: Success" )
9091192 print ("[OK] SQL Encoding: Success" )
9101193 print ("[OK] Batch Operations: Success" )
1194+ print ("[OK] Relationship Operations: Success" )
9111195 print ("\n Your PowerPlatform Dataverse Client SDK is fully functional!" )
9121196
9131197 # Cleanup
0 commit comments