Skip to content

Commit ac82c69

Browse files
Merge branch 'main' into feature/context-manager
2 parents ace31b0 + d391509 commit ac82c69

6 files changed

Lines changed: 697 additions & 2 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT license.
4+
5+
"""
6+
PowerPlatform Dataverse Client - Alternate Keys & Upsert Example
7+
8+
Demonstrates the full workflow of creating alternate keys and using
9+
them for upsert operations:
10+
1. Create a custom table with columns
11+
2. Define an alternate key on a column
12+
3. Wait for the key index to become Active
13+
4. Upsert records using the alternate key
14+
5. Verify records were created/updated correctly
15+
6. Clean up
16+
17+
Prerequisites:
18+
pip install PowerPlatform-Dataverse-Client
19+
pip install azure-identity
20+
"""
21+
22+
import sys
23+
import time
24+
25+
from PowerPlatform.Dataverse.client import DataverseClient
26+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
27+
from azure.identity import InteractiveBrowserCredential # type: ignore
28+
29+
# --- Config ---
30+
TABLE_NAME = "new_AltKeyDemo"
31+
KEY_COLUMN = "new_externalid"
32+
KEY_NAME = "new_ExternalIdKey"
33+
BACKOFF_DELAYS = (0, 3, 10, 20, 35)
34+
35+
36+
# --- Helpers ---
37+
def backoff(op, *, delays=BACKOFF_DELAYS):
38+
"""Retry *op* with exponential-ish backoff on any exception."""
39+
last = None
40+
total_delay = 0
41+
attempts = 0
42+
for d in delays:
43+
if d:
44+
time.sleep(d)
45+
total_delay += d
46+
attempts += 1
47+
try:
48+
result = op()
49+
if attempts > 1:
50+
retry_count = attempts - 1
51+
print(f" [INFO] Backoff succeeded after {retry_count} retry(s); " f"waited {total_delay}s total.")
52+
return result
53+
except Exception as ex: # noqa: BLE001
54+
last = ex
55+
continue
56+
if last:
57+
if attempts:
58+
retry_count = max(attempts - 1, 0)
59+
print(f" [WARN] Backoff exhausted after {retry_count} retry(s); " f"waited {total_delay}s total.")
60+
raise last
61+
62+
63+
def wait_for_key_active(client, table, key_name, max_wait=120):
64+
"""Poll get_alternate_keys until the key status is Active."""
65+
start = time.time()
66+
while time.time() - start < max_wait:
67+
keys = client.tables.get_alternate_keys(table)
68+
for k in keys:
69+
if k.schema_name == key_name:
70+
print(f" Key status: {k.status}")
71+
if k.status == "Active":
72+
return k
73+
if k.status == "Failed":
74+
raise RuntimeError(f"Alternate key index failed: {k.schema_name}")
75+
time.sleep(5)
76+
raise TimeoutError(f"Key {key_name} did not become Active within {max_wait}s")
77+
78+
79+
# --- Main ---
80+
def main():
81+
"""Run the alternate-keys & upsert E2E walkthrough."""
82+
print("PowerPlatform Dataverse Client - Alternate Keys & Upsert Example")
83+
print("=" * 70)
84+
print("This script demonstrates:")
85+
print(" - Creating a custom table with columns")
86+
print(" - Defining an alternate key on a column")
87+
print(" - Waiting for the key index to become Active")
88+
print(" - Upserting records via alternate key (create + update)")
89+
print(" - Verifying records and listing keys")
90+
print(" - Cleaning up (delete key, delete table)")
91+
print("=" * 70)
92+
93+
entered = input("Enter Dataverse org URL (e.g. https://yourorg.crm.dynamics.com): ").strip()
94+
if not entered:
95+
print("No URL entered; exiting.")
96+
sys.exit(1)
97+
98+
base_url = entered.rstrip("/")
99+
credential = InteractiveBrowserCredential()
100+
client = DataverseClient(base_url, credential)
101+
102+
# ------------------------------------------------------------------
103+
# Step 1: Create table
104+
# ------------------------------------------------------------------
105+
print("\n1. Creating table...")
106+
table_info = backoff(
107+
lambda: client.tables.create(
108+
TABLE_NAME,
109+
columns={
110+
KEY_COLUMN: "string",
111+
"new_ProductName": "string",
112+
"new_Price": "decimal",
113+
},
114+
)
115+
)
116+
print(f" Created: {table_info.get('table_schema_name', TABLE_NAME)}")
117+
118+
time.sleep(10) # Wait for metadata propagation
119+
120+
# ------------------------------------------------------------------
121+
# Step 2: Create alternate key
122+
# ------------------------------------------------------------------
123+
print("\n2. Creating alternate key...")
124+
key_info = backoff(lambda: client.tables.create_alternate_key(TABLE_NAME, KEY_NAME, [KEY_COLUMN.lower()]))
125+
print(f" Key created: {key_info.schema_name} (id={key_info.metadata_id})")
126+
127+
# ------------------------------------------------------------------
128+
# Step 3: Wait for key to become Active
129+
# ------------------------------------------------------------------
130+
print("\n3. Waiting for key index to become Active...")
131+
active_key = wait_for_key_active(client, TABLE_NAME, KEY_NAME)
132+
print(f" Key is Active: {active_key.schema_name}")
133+
134+
# ------------------------------------------------------------------
135+
# Step 4: Upsert records (creates new)
136+
# ------------------------------------------------------------------
137+
print("\n4a. Upsert single record (PATCH, creates new)...")
138+
client.records.upsert(
139+
TABLE_NAME,
140+
[
141+
UpsertItem(
142+
alternate_key={KEY_COLUMN.lower(): "EXT-001"},
143+
record={"new_productname": "Widget A", "new_price": 9.99},
144+
),
145+
],
146+
)
147+
print(" Upserted EXT-001 (single)")
148+
149+
print("\n4b. Upsert second record (single PATCH)...")
150+
client.records.upsert(
151+
TABLE_NAME,
152+
[
153+
UpsertItem(
154+
alternate_key={KEY_COLUMN.lower(): "EXT-002"},
155+
record={"new_productname": "Widget B", "new_price": 19.99},
156+
),
157+
],
158+
)
159+
print(" Upserted EXT-002 (single)")
160+
161+
print("\n4c. Upsert multiple records (UpsertMultiple bulk)...")
162+
client.records.upsert(
163+
TABLE_NAME,
164+
[
165+
UpsertItem(
166+
alternate_key={KEY_COLUMN.lower(): "EXT-003"},
167+
record={"new_productname": "Widget C", "new_price": 29.99},
168+
),
169+
UpsertItem(
170+
alternate_key={KEY_COLUMN.lower(): "EXT-004"},
171+
record={"new_productname": "Widget D", "new_price": 39.99},
172+
),
173+
],
174+
)
175+
print(" Upserted EXT-003, EXT-004 (bulk)")
176+
177+
# ------------------------------------------------------------------
178+
# Step 5a: Upsert single update (PATCH, record exists)
179+
# ------------------------------------------------------------------
180+
print("\n5a. Upsert single record (update existing via PATCH)...")
181+
client.records.upsert(
182+
TABLE_NAME,
183+
[
184+
UpsertItem(
185+
alternate_key={KEY_COLUMN.lower(): "EXT-001"},
186+
record={"new_productname": "Widget A v2", "new_price": 12.99},
187+
),
188+
],
189+
)
190+
print(" Updated EXT-001 (single)")
191+
192+
# ------------------------------------------------------------------
193+
# Step 5b: Upsert multiple update (UpsertMultiple, records exist)
194+
# ------------------------------------------------------------------
195+
print("\n5b. Upsert multiple records (update existing via UpsertMultiple)...")
196+
client.records.upsert(
197+
TABLE_NAME,
198+
[
199+
UpsertItem(
200+
alternate_key={KEY_COLUMN.lower(): "EXT-003"},
201+
record={"new_productname": "Widget C v2", "new_price": 31.99},
202+
),
203+
UpsertItem(
204+
alternate_key={KEY_COLUMN.lower(): "EXT-004"},
205+
record={"new_productname": "Widget D v2", "new_price": 41.99},
206+
),
207+
],
208+
)
209+
print(" Updated EXT-003, EXT-004 (bulk)")
210+
211+
# ------------------------------------------------------------------
212+
# Step 6: Verify
213+
# ------------------------------------------------------------------
214+
print("\n6. Verifying records...")
215+
for page in client.records.get(
216+
TABLE_NAME,
217+
select=["new_productname", "new_price", KEY_COLUMN.lower()],
218+
):
219+
for record in page:
220+
ext_id = record.get(KEY_COLUMN.lower(), "?")
221+
name = record.get("new_productname", "?")
222+
price = record.get("new_price", "?")
223+
print(f" {ext_id}: {name} @ ${price}")
224+
225+
# ------------------------------------------------------------------
226+
# Step 7: List alternate keys
227+
# ------------------------------------------------------------------
228+
print("\n7. Listing alternate keys...")
229+
keys = client.tables.get_alternate_keys(TABLE_NAME)
230+
for k in keys:
231+
print(f" {k.schema_name}: columns={k.key_attributes}, status={k.status}")
232+
233+
# ------------------------------------------------------------------
234+
# Step 8: Cleanup
235+
# ------------------------------------------------------------------
236+
cleanup = input("\n8. Delete table and cleanup? (Y/n): ").strip() or "y"
237+
if cleanup.lower() in ("y", "yes"):
238+
try:
239+
# Delete alternate key first
240+
for k in keys:
241+
client.tables.delete_alternate_key(TABLE_NAME, k.metadata_id)
242+
print(f" Deleted key: {k.schema_name}")
243+
time.sleep(5)
244+
backoff(lambda: client.tables.delete(TABLE_NAME))
245+
print(f" Deleted table: {TABLE_NAME}")
246+
except Exception as e: # noqa: BLE001
247+
print(f" Cleanup error: {e}")
248+
else:
249+
print(" Table kept for inspection.")
250+
251+
print("\nDone.")
252+
253+
254+
if __name__ == "__main__":
255+
main()

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,112 @@ def _delete_table(self, table_schema_name: str) -> None:
15011501
url = f"{self.api}/EntityDefinitions({metadata_id})"
15021502
r = self._request("delete", url)
15031503

1504+
# ------------------- Alternate key metadata helpers -------------------
1505+
1506+
def _create_alternate_key(
1507+
self,
1508+
table_schema_name: str,
1509+
key_name: str,
1510+
columns: List[str],
1511+
display_name_label=None,
1512+
) -> Dict[str, Any]:
1513+
"""Create an alternate key on a table.
1514+
1515+
Issues ``POST EntityDefinitions(LogicalName='{logical_name}')/Keys``
1516+
with ``EntityKeyMetadata`` payload.
1517+
1518+
:param table_schema_name: Schema name of the table.
1519+
:type table_schema_name: ``str``
1520+
:param key_name: Schema name for the new alternate key.
1521+
:type key_name: ``str``
1522+
:param columns: List of column logical names that compose the key.
1523+
:type columns: ``list[str]``
1524+
:param display_name_label: Label for the key display name.
1525+
:type display_name_label: ``Label`` or ``None``
1526+
1527+
:return: Dictionary with ``metadata_id``, ``schema_name``, and ``key_attributes``.
1528+
:rtype: ``dict[str, Any]``
1529+
1530+
:raises MetadataError: If the table does not exist.
1531+
:raises HttpError: If the Web API request fails.
1532+
"""
1533+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1534+
if not ent or not ent.get("MetadataId"):
1535+
raise MetadataError(
1536+
f"Table '{table_schema_name}' not found.",
1537+
subcode=METADATA_TABLE_NOT_FOUND,
1538+
)
1539+
1540+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1541+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys"
1542+
payload: Dict[str, Any] = {
1543+
"SchemaName": key_name,
1544+
"KeyAttributes": columns,
1545+
}
1546+
if display_name_label is not None:
1547+
payload["DisplayName"] = display_name_label.to_dict()
1548+
r = self._request("post", url, json=payload)
1549+
metadata_id = self._extract_id_from_header(r.headers.get("OData-EntityId"))
1550+
1551+
return {
1552+
"metadata_id": metadata_id,
1553+
"schema_name": key_name,
1554+
"key_attributes": columns,
1555+
}
1556+
1557+
def _get_alternate_keys(self, table_schema_name: str) -> List[Dict[str, Any]]:
1558+
"""List all alternate keys on a table.
1559+
1560+
Issues ``GET EntityDefinitions(LogicalName='{logical_name}')/Keys``.
1561+
1562+
:param table_schema_name: Schema name of the table.
1563+
:type table_schema_name: ``str``
1564+
1565+
:return: List of raw ``EntityKeyMetadata`` dictionaries.
1566+
:rtype: ``list[dict[str, Any]]``
1567+
1568+
:raises MetadataError: If the table does not exist.
1569+
:raises HttpError: If the Web API request fails.
1570+
"""
1571+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1572+
if not ent or not ent.get("MetadataId"):
1573+
raise MetadataError(
1574+
f"Table '{table_schema_name}' not found.",
1575+
subcode=METADATA_TABLE_NOT_FOUND,
1576+
)
1577+
1578+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1579+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys"
1580+
r = self._request("get", url)
1581+
return r.json().get("value", [])
1582+
1583+
def _delete_alternate_key(self, table_schema_name: str, key_id: str) -> None:
1584+
"""Delete an alternate key by metadata ID.
1585+
1586+
Issues ``DELETE EntityDefinitions(LogicalName='{logical_name}')/Keys({key_id})``.
1587+
1588+
:param table_schema_name: Schema name of the table.
1589+
:type table_schema_name: ``str``
1590+
:param key_id: Metadata GUID of the alternate key.
1591+
:type key_id: ``str``
1592+
1593+
:return: ``None``
1594+
:rtype: ``None``
1595+
1596+
:raises MetadataError: If the table does not exist.
1597+
:raises HttpError: If the Web API request fails.
1598+
"""
1599+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1600+
if not ent or not ent.get("MetadataId"):
1601+
raise MetadataError(
1602+
f"Table '{table_schema_name}' not found.",
1603+
subcode=METADATA_TABLE_NOT_FOUND,
1604+
)
1605+
1606+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1607+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys({key_id})"
1608+
self._request("delete", url)
1609+
15041610
def _create_table(
15051611
self,
15061612
table_schema_name: str,

0 commit comments

Comments
 (0)