Skip to content

Commit 91ada39

Browse files
Use CreateEntities API for table creation (#183)
### Summary This PR updates Dataverse table creation to use the CreateEntities API instead of posting to EntityDefinitions. It also updates the metadata payload types for supported attribute kinds to their corresponding Complex*Metadata variants so the request shape matches the new bulk/create-entities contract. ### Test Executed - Unit Tests are run against the change All 1389 Pass - Run basic functional test as a validation. - Black formatter pass. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a76f5db commit 91ada39

4 files changed

Lines changed: 298 additions & 53 deletions

File tree

examples/basic/functional_testing.py

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import sys
2929
import time
30+
from enum import Enum
3031
from typing import Optional, Dict, Any
3132
from datetime import datetime
3233

@@ -909,7 +910,12 @@ def test_batch_all_operations(client: DataverseClient, table_info: Dict[str, Any
909910
pass
910911

911912

912-
def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> None:
913+
def cleanup_test_data(
914+
client: DataverseClient,
915+
table_info: Dict[str, Any],
916+
record_id: str,
917+
picklist_table_schema_name: Optional[str] = None,
918+
) -> None:
913919
"""Clean up test data."""
914920
print("\n-> Cleanup")
915921
print("=" * 50)
@@ -979,6 +985,46 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor
979985
else:
980986
print("Test table kept for future testing")
981987

988+
# --- Picklist test table cleanup ---
989+
if picklist_table_schema_name:
990+
picklist_cleanup = (
991+
input(f"Do you want to delete the picklist test table '{picklist_table_schema_name}'? (y/N): ")
992+
.strip()
993+
.lower()
994+
)
995+
if picklist_cleanup in ["y", "yes"]:
996+
for attempt in range(1, retries + 1):
997+
try:
998+
client.tables.delete(picklist_table_schema_name)
999+
print(f"[OK] Picklist test table '{picklist_table_schema_name}' deleted successfully")
1000+
break
1001+
except HttpError as err:
1002+
status = getattr(err, "status_code", None)
1003+
if status == 404:
1004+
if _table_still_exists(client, picklist_table_schema_name):
1005+
if attempt < retries:
1006+
print(
1007+
f" Picklist table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..."
1008+
)
1009+
time.sleep(delay_seconds)
1010+
continue
1011+
print(f"[WARN] Failed to delete picklist test table due to metadata delay: {err}")
1012+
break
1013+
print("[OK] Picklist test table deleted successfully (404 reported).")
1014+
break
1015+
if attempt < retries:
1016+
print(
1017+
f" Picklist table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..."
1018+
)
1019+
time.sleep(delay_seconds)
1020+
continue
1021+
print(f"[WARN] Failed to delete picklist test table: {err}")
1022+
except Exception as e:
1023+
print(f"[WARN] Failed to delete picklist test table: {e}")
1024+
break
1025+
else:
1026+
print("Picklist test table kept for future testing")
1027+
9821028

9831029
def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
9841030
"""Retry helper with exponential backoff for metadata propagation delays."""
@@ -1260,6 +1306,118 @@ def _get_or_create(schema, columns, label):
12601306
print(f" [WARN] Could not delete {tbl}: {e}")
12611307

12621308

1309+
def test_picklist_table(client: DataverseClient) -> str:
1310+
"""Create a table with a local picklist column and write/read records.
1311+
1312+
Demonstrates:
1313+
- Defining a local OptionSet via an ``Enum`` subclass passed as the column ``dtype``.
1314+
- Optional multi-language labels via the ``__labels__`` class attribute.
1315+
- Writing records using either the enum member's integer value OR its label.
1316+
- Reading the integer value back, and the formatted label via
1317+
``include_annotations="OData.Community.Display.V1.FormattedValue"``.
1318+
1319+
Returns the schema name of the table so the caller can clean it up later.
1320+
"""
1321+
print("\n-> Picklist Column Test")
1322+
print("=" * 50)
1323+
1324+
table_schema_name = "test_PicklistAttribute"
1325+
1326+
# Define a local option set as an Enum. Optional __labels__ provides
1327+
# display labels per language code (1033 = English, 1036 = French).
1328+
class TaskStatus(Enum):
1329+
NotStarted = 1
1330+
InProgress = 2
1331+
Completed = 3
1332+
Cancelled = 4
1333+
1334+
__labels__ = {
1335+
1033: {
1336+
"NotStarted": "Not Started",
1337+
"InProgress": "In Progress",
1338+
"Completed": "Completed",
1339+
"Cancelled": "Cancelled",
1340+
},
1341+
1036: {
1342+
"NotStarted": "Non commencé",
1343+
"InProgress": "En cours",
1344+
"Completed": "Terminé",
1345+
"Cancelled": "Annulé",
1346+
},
1347+
}
1348+
1349+
record_id: Optional[str] = None
1350+
try:
1351+
# Drop any leftover table from a prior failed run so this example is idempotent.
1352+
try:
1353+
existing = client.tables.get(table_schema_name)
1354+
if existing:
1355+
print(f" Removing leftover '{table_schema_name}' from a previous run...")
1356+
client.tables.delete(table_schema_name)
1357+
except Exception:
1358+
pass
1359+
1360+
print(f"Creating table '{table_schema_name}' with a picklist column 'test_status'...")
1361+
1362+
client.tables.create(
1363+
table_schema_name,
1364+
primary_column="test_name",
1365+
columns={
1366+
"test_status": TaskStatus, # Enum subclass => local picklist
1367+
"test_notes": "string",
1368+
},
1369+
)
1370+
1371+
table_info = wait_for_table_metadata(client, table_schema_name)
1372+
print(f"[OK] Picklist table ready: entity_set='{table_info.get('entity_set_name')}'")
1373+
1374+
# --- Insert one record using the enum's integer value ---
1375+
rec_by_int = {
1376+
"test_name": f"Picklist Int {datetime.now().strftime('%H:%M:%S')}",
1377+
"test_status": TaskStatus.InProgress.value, # integer 2
1378+
"test_notes": "Created using TaskStatus.InProgress.value",
1379+
}
1380+
record_id = client.records.create(table_schema_name, rec_by_int)
1381+
print(f"[OK] Created record by int value: {record_id} (status={TaskStatus.InProgress.value})")
1382+
1383+
# --- Insert another record using the picklist label (SDK resolves label -> int) ---
1384+
rec_by_label = {
1385+
"test_name": f"Picklist Label {datetime.now().strftime('%H:%M:%S')}",
1386+
"test_status": "Completed", # resolved via label cache to int 3
1387+
"test_notes": "Created using label string 'Completed'",
1388+
}
1389+
record_id_2 = client.records.create(table_schema_name, rec_by_label)
1390+
print(f"[OK] Created record by label: {record_id_2} (status='Completed' -> 3)")
1391+
1392+
# --- Read back including the FormattedValue annotation ---
1393+
annotation = "OData.Community.Display.V1.FormattedValue"
1394+
retrieved = client.records.retrieve(
1395+
table_schema_name,
1396+
record_id,
1397+
select=["test_name", "test_status"],
1398+
include_annotations=annotation,
1399+
)
1400+
status_int = retrieved.get("test_status")
1401+
status_label = retrieved.get(f"test_status@{annotation}")
1402+
print(f" Retrieved: test_status={status_int}, formatted='{status_label}'")
1403+
assert status_int == TaskStatus.InProgress.value, f"expected {TaskStatus.InProgress.value}, got {status_int}"
1404+
1405+
# --- List records, filtering by the picklist column ---
1406+
completed = client.records.list(
1407+
table_schema_name,
1408+
select=["test_name", "test_status"],
1409+
filter=f"test_status eq {TaskStatus.Completed.value}",
1410+
include_annotations=annotation,
1411+
)
1412+
print(f"[OK] Query by picklist value found {len(completed)} 'Completed' record(s).")
1413+
1414+
except HttpError as e:
1415+
print(f"[ERR] HTTP error during picklist test: {e}")
1416+
raise
1417+
1418+
return table_schema_name
1419+
1420+
12631421
def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool:
12641422
if not table_schema_name:
12651423
return False
@@ -1304,6 +1462,9 @@ def main():
13041462
# Test querying
13051463
test_query_records(client, table_info)
13061464

1465+
# Test picklist (local OptionSet) column creation, write, read
1466+
picklist_table_info = test_picklist_table(client)
1467+
13071468
# Test relationships
13081469
test_relationships(client)
13091470

@@ -1321,13 +1482,14 @@ def main():
13211482
print("[OK] Record Creation: Success")
13221483
print("[OK] Record Reading: Success")
13231484
print("[OK] Record Querying: Success")
1485+
print("[OK] Picklist Column: Success")
13241486
print("[OK] Relationship Operations: Success")
13251487
print("[OK] SQL Encoding: Success")
13261488
print("[OK] Batch Operations: Success")
13271489
print("\nYour PowerPlatform Dataverse Client SDK is fully functional!")
13281490

13291491
# Cleanup
1330-
cleanup_test_data(client, table_info, record_id)
1492+
cleanup_test_data(client, table_info, record_id, picklist_table_info)
13311493

13321494
except KeyboardInterrupt:
13331495
print("\n\n[WARN] Test interrupted by user")

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -816,18 +816,22 @@ def _create_entity(
816816
attributes: List[Dict[str, Any]],
817817
solution_unique_name: Optional[str] = None,
818818
) -> Dict[str, Any]:
819-
url = f"{self.api}/EntityDefinitions"
819+
url = f"{self.api}/CreateEntities"
820820
payload = {
821-
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
822-
"SchemaName": table_schema_name,
823-
"DisplayName": self._label(display_name),
824-
"DisplayCollectionName": self._label(display_name + "s"),
825-
"Description": self._label(f"Custom entity for {display_name}"),
826-
"OwnershipType": "UserOwned",
827-
"HasActivities": False,
828-
"HasNotes": True,
829-
"IsActivity": False,
830-
"Attributes": attributes,
821+
"Entities": [
822+
{
823+
"@odata.type": "Microsoft.Dynamics.CRM.ComplexEntityMetadata",
824+
"SchemaName": table_schema_name,
825+
"DisplayName": self._label(display_name),
826+
"DisplayCollectionName": self._label(display_name + "s"),
827+
"Description": self._label(f"Custom entity for {display_name}"),
828+
"OwnershipType": "UserOwned",
829+
"HasActivities": False,
830+
"HasNotes": True,
831+
"IsActivity": False,
832+
"Attributes": attributes,
833+
}
834+
]
831835
}
832836
params = None
833837
if solution_unique_name:
@@ -1298,9 +1302,9 @@ def _create_table(
12981302
)
12991303

13001304
attributes: List[Dict[str, Any]] = []
1301-
attributes.append(self._attribute_payload(primary_attr_schema, "string", is_primary_name=True))
1305+
attributes.append(self._attribute_payload(primary_attr_schema, "string", is_primary_name=True, complex=True))
13021306
for col_name, dtype in schema.items():
1303-
payload = self._attribute_payload(col_name, dtype)
1307+
payload = self._attribute_payload(col_name, dtype, complex=True)
13041308
if not payload:
13051309
raise ValueError(f"Unsupported column type '{dtype}' for '{col_name}'.")
13061310
attributes.append(payload)

0 commit comments

Comments
 (0)