Skip to content

Commit 1799439

Browse files
tpellissierclaude
andcommitted
Add alternate key management (create, get, delete) for upsert support
Adds client.tables.create_alternate_key(), get_alternate_keys(), and delete_alternate_key() with AlternateKeyInfo typed return model. Enables programmatic setup of alternate keys required for upsert operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a3cb1b commit 1799439

5 files changed

Lines changed: 428 additions & 0 deletions

File tree

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,102 @@ def _delete_table(self, table_schema_name: str) -> None:
14861486
url = f"{self.api}/EntityDefinitions({metadata_id})"
14871487
r = self._request("delete", url)
14881488

1489+
# ------------------- Alternate key metadata helpers -------------------
1490+
1491+
def _create_alternate_key(self, table_schema_name: str, key_name: str, columns: List[str]) -> Dict[str, Any]:
1492+
"""Create an alternate key on a table.
1493+
1494+
Issues ``POST EntityDefinitions(LogicalName='{logical_name}')/Keys``
1495+
with payload ``{"SchemaName": key_name, "KeyAttributes": columns}``.
1496+
1497+
:param table_schema_name: Schema name of the table.
1498+
:type table_schema_name: ``str``
1499+
:param key_name: Schema name for the new alternate key.
1500+
:type key_name: ``str``
1501+
:param columns: List of column logical names that compose the key.
1502+
:type columns: ``list[str]``
1503+
1504+
:return: Dictionary with ``metadata_id``, ``schema_name``, and ``key_attributes``.
1505+
:rtype: ``dict[str, Any]``
1506+
1507+
:raises MetadataError: If the table does not exist.
1508+
:raises HttpError: If the Web API request fails.
1509+
"""
1510+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1511+
if not ent or not ent.get("MetadataId"):
1512+
raise MetadataError(
1513+
f"Table '{table_schema_name}' not found.",
1514+
subcode=METADATA_TABLE_NOT_FOUND,
1515+
)
1516+
1517+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1518+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys"
1519+
payload = {
1520+
"SchemaName": key_name,
1521+
"KeyAttributes": columns,
1522+
}
1523+
r = self._request("post", url, json=payload)
1524+
metadata_id = self._extract_id_from_header(r.headers.get("OData-EntityId"))
1525+
1526+
return {
1527+
"metadata_id": metadata_id,
1528+
"schema_name": key_name,
1529+
"key_attributes": columns,
1530+
}
1531+
1532+
def _get_alternate_keys(self, table_schema_name: str) -> List[Dict[str, Any]]:
1533+
"""List all alternate keys on a table.
1534+
1535+
Issues ``GET EntityDefinitions(LogicalName='{logical_name}')/Keys``.
1536+
1537+
:param table_schema_name: Schema name of the table.
1538+
:type table_schema_name: ``str``
1539+
1540+
:return: List of raw ``EntityKeyMetadata`` dictionaries.
1541+
:rtype: ``list[dict[str, Any]]``
1542+
1543+
:raises MetadataError: If the table does not exist.
1544+
:raises HttpError: If the Web API request fails.
1545+
"""
1546+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1547+
if not ent or not ent.get("MetadataId"):
1548+
raise MetadataError(
1549+
f"Table '{table_schema_name}' not found.",
1550+
subcode=METADATA_TABLE_NOT_FOUND,
1551+
)
1552+
1553+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1554+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys"
1555+
r = self._request("get", url)
1556+
return r.json().get("value", [])
1557+
1558+
def _delete_alternate_key(self, table_schema_name: str, key_id: str) -> None:
1559+
"""Delete an alternate key by metadata ID.
1560+
1561+
Issues ``DELETE EntityDefinitions(LogicalName='{logical_name}')/Keys({key_id})``.
1562+
1563+
:param table_schema_name: Schema name of the table.
1564+
:type table_schema_name: ``str``
1565+
:param key_id: Metadata GUID of the alternate key.
1566+
:type key_id: ``str``
1567+
1568+
:return: ``None``
1569+
:rtype: ``None``
1570+
1571+
:raises MetadataError: If the table does not exist.
1572+
:raises HttpError: If the Web API request fails.
1573+
"""
1574+
ent = self._get_entity_by_table_schema_name(table_schema_name)
1575+
if not ent or not ent.get("MetadataId"):
1576+
raise MetadataError(
1577+
f"Table '{table_schema_name}' not found.",
1578+
subcode=METADATA_TABLE_NOT_FOUND,
1579+
)
1580+
1581+
logical_name = ent.get("LogicalName", table_schema_name.lower())
1582+
url = f"{self.api}/EntityDefinitions(LogicalName='{logical_name}')/Keys({key_id})"
1583+
self._request("delete", url)
1584+
14891585
def _create_table(
14901586
self,
14911587
table_schema_name: str,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Table metadata models for Dataverse."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass, field
9+
from typing import Any, Dict, List
10+
11+
12+
@dataclass
13+
class AlternateKeyInfo:
14+
"""Alternate key metadata for a Dataverse table.
15+
16+
:param metadata_id: Key metadata GUID.
17+
:type metadata_id: :class:`str`
18+
:param schema_name: Key schema name.
19+
:type schema_name: :class:`str`
20+
:param key_attributes: List of column logical names that compose the key.
21+
:type key_attributes: :class:`list` of :class:`str`
22+
:param status: Index creation status (``"Active"``, ``"Pending"``, ``"InProgress"``, ``"Failed"``).
23+
:type status: :class:`str`
24+
"""
25+
26+
metadata_id: str = ""
27+
schema_name: str = ""
28+
key_attributes: List[str] = field(default_factory=list)
29+
status: str = ""
30+
31+
@classmethod
32+
def from_api_response(cls, response_data: Dict[str, Any]) -> AlternateKeyInfo:
33+
"""Create from raw EntityKeyMetadata API response.
34+
35+
:param response_data: Raw key metadata dictionary from the Web API.
36+
:type response_data: :class:`dict`
37+
:rtype: :class:`AlternateKeyInfo`
38+
"""
39+
return cls(
40+
metadata_id=response_data.get("MetadataId", ""),
41+
schema_name=response_data.get("SchemaName", ""),
42+
key_attributes=response_data.get("KeyAttributes", []),
43+
status=response_data.get("EntityKeyIndexStatus", ""),
44+
)
45+
46+
47+
__all__ = ["AlternateKeyInfo"]

src/PowerPlatform/Dataverse/operations/tables.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CascadeConfiguration,
1515
RelationshipInfo,
1616
)
17+
from ..models.table_info import AlternateKeyInfo
1718
from ..models.labels import Label, LocalizedLabel
1819
from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK
1920

@@ -578,3 +579,111 @@ def create_lookup_field(
578579
)
579580

580581
return self.create_one_to_many_relationship(lookup, relationship, solution=solution)
582+
583+
# ------------------------------------------------- create_alternate_key
584+
585+
def create_alternate_key(
586+
self,
587+
table: str,
588+
key_name: str,
589+
columns: List[str],
590+
) -> AlternateKeyInfo:
591+
"""Create an alternate key on a table.
592+
593+
Alternate keys allow upsert operations to identify records by one or
594+
more columns instead of the primary GUID. After creation the key is
595+
queued for index building; its :attr:`~AlternateKeyInfo.status` will
596+
transition from ``"Pending"`` to ``"Active"`` once the index is ready.
597+
598+
:param table: Schema name of the table (e.g. ``"new_Product"``).
599+
:type table: :class:`str`
600+
:param key_name: Schema name for the new alternate key
601+
(e.g. ``"new_product_code_key"``).
602+
:type key_name: :class:`str`
603+
:param columns: List of column logical names that compose the key
604+
(e.g. ``["new_productcode"]``).
605+
:type columns: :class:`list` of :class:`str`
606+
607+
:return: Metadata for the newly created alternate key.
608+
:rtype: :class:`~PowerPlatform.Dataverse.models.table_info.AlternateKeyInfo`
609+
610+
:raises ~PowerPlatform.Dataverse.core.errors.MetadataError:
611+
If the table does not exist.
612+
:raises ~PowerPlatform.Dataverse.core.errors.HttpError:
613+
If the Web API request fails.
614+
615+
Example:
616+
Create a single-column alternate key for upsert::
617+
618+
key = client.tables.create_alternate_key(
619+
"new_Product",
620+
"new_product_code_key",
621+
["new_productcode"],
622+
)
623+
print(f"Key ID: {key.metadata_id}")
624+
print(f"Columns: {key.key_attributes}")
625+
"""
626+
with self._client._scoped_odata() as od:
627+
raw = od._create_alternate_key(table, key_name, columns)
628+
return AlternateKeyInfo(
629+
metadata_id=raw["metadata_id"],
630+
schema_name=raw["schema_name"],
631+
key_attributes=raw["key_attributes"],
632+
)
633+
634+
# --------------------------------------------------- get_alternate_keys
635+
636+
def get_alternate_keys(self, table: str) -> List[AlternateKeyInfo]:
637+
"""List all alternate keys defined on a table.
638+
639+
:param table: Schema name of the table (e.g. ``"new_Product"``).
640+
:type table: :class:`str`
641+
642+
:return: List of alternate key metadata objects. May be empty if no
643+
alternate keys are defined.
644+
:rtype: :class:`list` of :class:`~PowerPlatform.Dataverse.models.table_info.AlternateKeyInfo`
645+
646+
:raises ~PowerPlatform.Dataverse.core.errors.MetadataError:
647+
If the table does not exist.
648+
:raises ~PowerPlatform.Dataverse.core.errors.HttpError:
649+
If the Web API request fails.
650+
651+
Example:
652+
List alternate keys and print their status::
653+
654+
keys = client.tables.get_alternate_keys("new_Product")
655+
for key in keys:
656+
print(f"{key.schema_name}: {key.status}")
657+
"""
658+
with self._client._scoped_odata() as od:
659+
raw_list = od._get_alternate_keys(table)
660+
return [AlternateKeyInfo.from_api_response(item) for item in raw_list]
661+
662+
# ------------------------------------------------ delete_alternate_key
663+
664+
def delete_alternate_key(self, table: str, key_id: str) -> None:
665+
"""Delete an alternate key by its metadata ID.
666+
667+
:param table: Schema name of the table (e.g. ``"new_Product"``).
668+
:type table: :class:`str`
669+
:param key_id: Metadata GUID of the alternate key to delete.
670+
:type key_id: :class:`str`
671+
672+
:raises ~PowerPlatform.Dataverse.core.errors.MetadataError:
673+
If the table does not exist.
674+
:raises ~PowerPlatform.Dataverse.core.errors.HttpError:
675+
If the Web API request fails.
676+
677+
.. warning::
678+
Deleting an alternate key that is in use by upsert operations will
679+
cause those operations to fail. This operation is irreversible.
680+
681+
Example::
682+
683+
client.tables.delete_alternate_key(
684+
"new_Product",
685+
"12345678-1234-1234-1234-123456789abc",
686+
)
687+
"""
688+
with self._client._scoped_odata() as od:
689+
od._delete_alternate_key(table, key_id)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import unittest
5+
6+
from PowerPlatform.Dataverse.models.table_info import AlternateKeyInfo
7+
8+
9+
class TestAlternateKeyInfoDefaults(unittest.TestCase):
10+
"""Tests for AlternateKeyInfo default values."""
11+
12+
def test_default_values(self):
13+
"""All fields should default to empty string or empty list."""
14+
info = AlternateKeyInfo()
15+
self.assertEqual(info.metadata_id, "")
16+
self.assertEqual(info.schema_name, "")
17+
self.assertEqual(info.key_attributes, [])
18+
self.assertEqual(info.status, "")
19+
20+
def test_independent_default_lists(self):
21+
"""Each instance should have its own key_attributes list (no shared mutable default)."""
22+
a = AlternateKeyInfo()
23+
b = AlternateKeyInfo()
24+
a.key_attributes.append("col1")
25+
self.assertEqual(b.key_attributes, [])
26+
27+
28+
class TestAlternateKeyInfoFromApiResponse(unittest.TestCase):
29+
"""Tests for AlternateKeyInfo.from_api_response factory."""
30+
31+
def test_full_response(self):
32+
"""from_api_response should map all PascalCase API fields."""
33+
raw = {
34+
"MetadataId": "key-guid-1",
35+
"SchemaName": "new_product_code_key",
36+
"KeyAttributes": ["new_productcode"],
37+
"EntityKeyIndexStatus": "Active",
38+
}
39+
info = AlternateKeyInfo.from_api_response(raw)
40+
self.assertEqual(info.metadata_id, "key-guid-1")
41+
self.assertEqual(info.schema_name, "new_product_code_key")
42+
self.assertEqual(info.key_attributes, ["new_productcode"])
43+
self.assertEqual(info.status, "Active")
44+
45+
def test_multi_column_key(self):
46+
"""from_api_response should handle multi-column keys."""
47+
raw = {
48+
"MetadataId": "key-guid-2",
49+
"SchemaName": "new_composite_key",
50+
"KeyAttributes": ["new_col1", "new_col2", "new_col3"],
51+
"EntityKeyIndexStatus": "Pending",
52+
}
53+
info = AlternateKeyInfo.from_api_response(raw)
54+
self.assertEqual(info.key_attributes, ["new_col1", "new_col2", "new_col3"])
55+
self.assertEqual(info.status, "Pending")
56+
57+
def test_minimal_response(self):
58+
"""from_api_response should handle a response with missing optional fields."""
59+
raw = {}
60+
info = AlternateKeyInfo.from_api_response(raw)
61+
self.assertEqual(info.metadata_id, "")
62+
self.assertEqual(info.schema_name, "")
63+
self.assertEqual(info.key_attributes, [])
64+
self.assertEqual(info.status, "")
65+
66+
def test_partial_response(self):
67+
"""from_api_response should handle a response with only some fields."""
68+
raw = {
69+
"MetadataId": "key-guid-3",
70+
"SchemaName": "new_partial_key",
71+
}
72+
info = AlternateKeyInfo.from_api_response(raw)
73+
self.assertEqual(info.metadata_id, "key-guid-3")
74+
self.assertEqual(info.schema_name, "new_partial_key")
75+
self.assertEqual(info.key_attributes, [])
76+
self.assertEqual(info.status, "")
77+
78+
79+
if __name__ == "__main__":
80+
unittest.main()

0 commit comments

Comments
 (0)