Skip to content

Commit f99777b

Browse files
author
Saurabh Badenkal
committed
Merge origin/main into users/sagebree/batch (resolve conflicts)
2 parents e19c6c4 + 9788cbb commit f99777b

4 files changed

Lines changed: 1055 additions & 0 deletions

File tree

examples/basic/functional_testing.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
from PowerPlatform.Dataverse.client import DataverseClient
3535
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError
3636
from 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+
)
3748
from 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("\nCreating 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+
8521131
def _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("\nFunctional 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("\nYour PowerPlatform Dataverse Client SDK is fully functional!")
9121196

9131197
# Cleanup

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,11 @@ select = [
9090
"UP", # pyupgrade
9191
"B", # flake8-bugbear
9292
]
93+
94+
[tool.pytest.ini_options]
95+
testpaths = ["tests/unit"]
96+
markers = [
97+
"e2e: end-to-end tests requiring a live Dataverse environment (DATAVERSE_URL)",
98+
]
99+
# e2e tests require a live Dataverse environment:
100+
# DATAVERSE_URL=https://yourorg.crm.dynamics.com pytest tests/e2e/ -v -s

tests/e2e/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.

0 commit comments

Comments
 (0)