Skip to content

Commit d391509

Browse files
tpellissier-msfttpellissierclaude
authored
Add alternate key management for upsert support (microsoft#126)
## Summary - Adds `AlternateKeyInfo` dataclass model in new `models/table_info.py` for typed alternate key metadata - Adds three OData layer methods (`_create_alternate_key`, `_get_alternate_keys`, `_delete_alternate_key`) to `_odata.py` - Adds three public operations (`create_alternate_key`, `get_alternate_keys`, `delete_alternate_key`) to `TableOperations` in `tables.py` - Enables programmatic setup of alternate keys required for upsert operations ## Test plan - [x] Unit tests for `AlternateKeyInfo.from_api_response` with full, minimal, partial, and multi-column data - [x] Unit tests for default values and independent mutable defaults on `AlternateKeyInfo` - [x] Unit test for `create_alternate_key` calls OData layer correctly and returns `AlternateKeyInfo` - [x] Unit test for `create_alternate_key` with multi-column keys - [x] Unit test for `get_alternate_keys` returns list of `AlternateKeyInfo` from API response - [x] Unit test for `get_alternate_keys` returns empty list when no keys exist - [x] Unit test for `delete_alternate_key` calls OData layer with correct args - [x] All 198 unit tests pass (`python -m pytest tests/unit/ -v`) - [x] Code formatted with `python -m black` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: tpellissier <tpellissier@microsoft.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 63cb9cb commit d391509

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
@@ -1485,6 +1485,112 @@ def _delete_table(self, table_schema_name: str) -> None:
14851485
url = f"{self.api}/EntityDefinitions({metadata_id})"
14861486
r = self._request("delete", url)
14871487

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

0 commit comments

Comments
 (0)