Skip to content

Commit ca7fcad

Browse files
author
Saurabh Badenkal
committed
Expose primary_name_attribute in TableInfo, fix prodev example E2E
SDK changes: - _get_entity_by_table_schema_name now selects PrimaryNameAttribute and PrimaryIdAttribute from EntityDefinitions - _create_table returns primary_name_attribute in its result dict - TableInfo model includes primary_name_attribute and primary_id_attribute fields with legacy key access - Fixes #148: tables.create() now exposes the primary column logical name Example changes: - prodev_quick_start.py uses result.primary_name_attribute from tables.create() to construct correct record payloads (fixes the demo_*_name column error) - Both examples verified E2E against live Dataverse
1 parent 860d10b commit ca7fcad

5 files changed

Lines changed: 85 additions & 26 deletions

File tree

examples/advanced/datascience_risk_assessment.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
4) Write risk assessments back to Dataverse
2121
5) Produce a summary report
2222
23+
Note: This example reads from existing Dataverse tables (account,
24+
incident, opportunity) and does not create or delete any tables.
25+
Step 4 (write-back) is disabled by default -- uncomment it in
26+
run_risk_pipeline() to write risk scores back to account records.
27+
2328
Prerequisites (required -- included in SDK dependencies):
2429
pip install PowerPlatform-Dataverse-Client
2530
pip install azure-identity

examples/advanced/prodev_quick_start.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
5) Query and join data across tables
1717
6) Clean up (delete tables)
1818
19+
Note: The last step (cleanup) automatically deletes all demo tables.
20+
Comment out the cleanup() call in run_demo() if you want to keep the
21+
tables in your environment for inspection.
22+
1923
Why pandas DataFrames?
2024
This example uses client.dataframe (pandas) instead of raw dict/list CRUD
2125
because DataFrames provide significant advantages for multi-record operations:
@@ -53,12 +57,14 @@
5357

5458
from PowerPlatform.Dataverse.client import DataverseClient
5559

56-
# -- Table schema names (prefixed to avoid collisions) --
57-
PREFIX = f"demo_{uuid.uuid4().hex[:6]}"
58-
TABLE_CUSTOMER = f"{PREFIX}_Customer"
59-
TABLE_PROJECT = f"{PREFIX}_Project"
60-
TABLE_TASK = f"{PREFIX}_Task"
61-
TABLE_TIMEENTRY = f"{PREFIX}_TimeEntry"
60+
# -- Table schema names --
61+
# Uses the standard 'new_' publisher prefix (default Dataverse publisher).
62+
# A unique suffix avoids collisions with existing tables.
63+
SUFFIX = uuid.uuid4().hex[:6]
64+
TABLE_CUSTOMER = f"new_DemoCustomer{SUFFIX}"
65+
TABLE_PROJECT = f"new_DemoProject{SUFFIX}"
66+
TABLE_TASK = f"new_DemoTask{SUFFIX}"
67+
TABLE_TIMEENTRY = f"new_DemoTimeEntry{SUFFIX}"
6268

6369
# -- Output folder for exported data (relative to this script) --
6470
_SCRIPT_DIR = Path(__file__).resolve().parent
@@ -100,19 +106,19 @@ def run_demo(client):
100106
print(f"[INFO] Output folder: {OUTPUT_DIR.resolve()}")
101107

102108
# -- Step 1: Create 4 tables --
103-
step1_create_tables(client)
109+
primary_name_col = step1_create_tables(client)
104110

105111
# -- Step 2: Create relationships --
106112
step2_create_relationships(client)
107113

108114
# -- Step 3: Populate with sample data --
109-
customer_ids, project_ids, task_ids = step3_populate_data(client)
115+
customer_ids, project_ids, task_ids = step3_populate_data(client, primary_name_col)
110116

111117
# -- Step 4: Query and analyze --
112-
step4_query_and_analyze(client, customer_ids)
118+
step4_query_and_analyze(client, customer_ids, primary_name_col)
113119

114120
# -- Step 5: Update and delete --
115-
step5_update_and_delete(client, task_ids)
121+
step5_update_and_delete(client, task_ids, primary_name_col)
116122

117123
# -- Step 6: Cleanup --
118124
cleanup(client)
@@ -134,15 +140,18 @@ def step1_create_tables(client):
134140
print("-" * 60)
135141

136142
# Customer table
137-
client.tables.create(
143+
result = client.tables.create(
138144
TABLE_CUSTOMER,
139145
{
140146
f"{TABLE_CUSTOMER}_Email": "string",
141147
f"{TABLE_CUSTOMER}_Industry": "string",
142148
f"{TABLE_CUSTOMER}_Revenue": "money",
143149
},
144150
)
145-
print(f"[OK] Created table: {TABLE_CUSTOMER}")
151+
# The primary name column logical name is returned by tables.create()
152+
# so we know exactly what key to use in create payloads.
153+
primary_name_col = result.primary_name_attribute
154+
print(f"[OK] Created table: {TABLE_CUSTOMER} (primary column: {primary_name_col})")
146155

147156
# Project table
148157
client.tables.create(
@@ -176,7 +185,10 @@ def step1_create_tables(client):
176185
},
177186
)
178187
print(f"[OK] Created table: {TABLE_TIMEENTRY}")
179-
print(f"[OK] All 4 tables created with prefix '{PREFIX}'")
188+
print(f"[OK] All 4 tables created (suffix: {SUFFIX})")
189+
print(f"[INFO] Primary name column: '{primary_name_col}'")
190+
191+
return primary_name_col
180192

181193

182194
# ================================================================
@@ -225,7 +237,7 @@ def step2_create_relationships(client):
225237
# ================================================================
226238

227239

228-
def step3_populate_data(client):
240+
def step3_populate_data(client, primary_name_col):
229241
"""Create sample records using client.dataframe.create().
230242
231243
Why DataFrames here instead of client.records.create()?
@@ -252,10 +264,8 @@ def step3_populate_data(client):
252264
print("-" * 60)
253265

254266
# -- Customers --
255-
# Note: The primary column logical name is {prefix}_name (lowercase),
256-
# and custom column logical names follow the same lowercase convention.
257-
# The SDK lowercases keys automatically, so we use the schema names here.
258-
name_col = f"{PREFIX}_Name"
267+
# Use the primary name column returned by tables.create()
268+
name_col = primary_name_col
259269
customers_df = pd.DataFrame(
260270
[
261271
{
@@ -364,15 +374,15 @@ def step3_populate_data(client):
364374
# ================================================================
365375

366376

367-
def step4_query_and_analyze(client, customer_ids):
377+
def step4_query_and_analyze(client, customer_ids, primary_name_col):
368378
"""Query data and demonstrate DataFrame analysis."""
369379
print("\n" + "-" * 60)
370380
print("STEP 4: Query and analyze data")
371381
print("-" * 60)
372382

373383
# Query all projects as a DataFrame
374384
# Note: select uses logical names (lowercase). The SDK lowercases automatically.
375-
name_attr = f"{PREFIX}_name" # primary column logical name
385+
name_attr = primary_name_col
376386
projects = client.dataframe.get(
377387
TABLE_PROJECT,
378388
select=[
@@ -437,7 +447,7 @@ def step4_query_and_analyze(client, customer_ids):
437447
# ================================================================
438448

439449

440-
def step5_update_and_delete(client, task_ids):
450+
def step5_update_and_delete(client, task_ids, primary_name_col):
441451
"""Demonstrate update and delete with DataFrames."""
442452
print("\n" + "-" * 60)
443453
print("STEP 5: Update and delete records")
@@ -461,10 +471,9 @@ def step5_update_and_delete(client, task_ids):
461471
print(f"[OK] Deleted 1 task")
462472

463473
# Verify
464-
name_attr = f"{PREFIX}_name"
465474
remaining = client.dataframe.get(
466475
TABLE_TASK,
467-
select=[name_attr, status_col],
476+
select=[primary_name_col, status_col],
468477
)
469478
print(f"\n Remaining tasks ({len(remaining)}):")
470479
print(f"{remaining.to_string(index=False)}")

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,7 @@ def _get_entity_by_table_schema_name(
960960
logical_lower = table_schema_name.lower()
961961
logical_escaped = self._escape_odata_quotes(logical_lower)
962962
params = {
963-
"$select": "MetadataId,LogicalName,SchemaName,EntitySetName",
963+
"$select": "MetadataId,LogicalName,SchemaName,EntitySetName,PrimaryNameAttribute,PrimaryIdAttribute",
964964
"$filter": f"LogicalName eq '{logical_escaped}'",
965965
}
966966
r = self._request("get", url, params=params, headers=headers)
@@ -1445,6 +1445,8 @@ def _get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]:
14451445
"table_logical_name": ent.get("LogicalName"),
14461446
"entity_set_name": ent.get("EntitySetName"),
14471447
"metadata_id": ent.get("MetadataId"),
1448+
"primary_name_attribute": ent.get("PrimaryNameAttribute"),
1449+
"primary_id_attribute": ent.get("PrimaryIdAttribute"),
14481450
"columns_created": [],
14491451
}
14501452

@@ -1689,6 +1691,8 @@ def _create_table(
16891691
"table_logical_name": metadata.get("LogicalName"),
16901692
"entity_set_name": metadata.get("EntitySetName"),
16911693
"metadata_id": metadata.get("MetadataId"),
1694+
"primary_name_attribute": metadata.get("PrimaryNameAttribute"),
1695+
"primary_id_attribute": metadata.get("PrimaryIdAttribute"),
16921696
"columns_created": created_cols,
16931697
}
16941698

src/PowerPlatform/Dataverse/models/table_info.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ class TableInfo:
112112
logical_name: str = ""
113113
entity_set_name: str = ""
114114
metadata_id: str = ""
115+
primary_name_attribute: Optional[str] = None
116+
primary_id_attribute: Optional[str] = None
115117
display_name: Optional[str] = None
116118
description: Optional[str] = None
117119
columns: Optional[List[ColumnInfo]] = field(default=None, repr=False)
@@ -123,6 +125,8 @@ class TableInfo:
123125
"table_logical_name": "logical_name",
124126
"entity_set_name": "entity_set_name",
125127
"metadata_id": "metadata_id",
128+
"primary_name_attribute": "primary_name_attribute",
129+
"primary_id_attribute": "primary_id_attribute",
126130
"columns_created": "columns_created",
127131
}
128132

@@ -187,6 +191,8 @@ def from_dict(cls, data: Dict[str, Any]) -> TableInfo:
187191
logical_name=data.get("table_logical_name", ""),
188192
entity_set_name=data.get("entity_set_name", ""),
189193
metadata_id=data.get("metadata_id", ""),
194+
primary_name_attribute=data.get("primary_name_attribute"),
195+
primary_id_attribute=data.get("primary_id_attribute"),
190196
columns_created=data.get("columns_created"),
191197
)
192198

@@ -213,6 +219,8 @@ def from_api_response(cls, response_data: Dict[str, Any]) -> TableInfo:
213219
logical_name=response_data.get("LogicalName", ""),
214220
entity_set_name=response_data.get("EntitySetName", ""),
215221
metadata_id=response_data.get("MetadataId", ""),
222+
primary_name_attribute=response_data.get("PrimaryNameAttribute"),
223+
primary_id_attribute=response_data.get("PrimaryIdAttribute"),
216224
display_name=display_name,
217225
description=description,
218226
)

tests/unit/models/test_table_info.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,19 @@ def test_legacy_key_iteration(self):
4949
keys = list(self.info)
5050
self.assertEqual(
5151
keys,
52-
["table_schema_name", "table_logical_name", "entity_set_name", "metadata_id", "columns_created"],
52+
[
53+
"table_schema_name",
54+
"table_logical_name",
55+
"entity_set_name",
56+
"metadata_id",
57+
"primary_name_attribute",
58+
"primary_id_attribute",
59+
"columns_created",
60+
],
5361
)
5462

5563
def test_len(self):
56-
self.assertEqual(len(self.info), 5)
64+
self.assertEqual(len(self.info), 7)
5765

5866
def test_keys_values_items(self):
5967
self.assertEqual(list(self.info.keys()), list(self.info._LEGACY_KEY_MAP.keys()))
@@ -76,20 +84,41 @@ def test_from_dict(self):
7684
"table_logical_name": "new_product",
7785
"entity_set_name": "new_products",
7886
"metadata_id": "meta-guid-1",
87+
"primary_name_attribute": "new_name",
88+
"primary_id_attribute": "new_productid",
7989
"columns_created": ["new_Price"],
8090
}
8191
info = TableInfo.from_dict(data)
8292
self.assertEqual(info.schema_name, "new_Product")
8393
self.assertEqual(info.logical_name, "new_product")
8494
self.assertEqual(info.entity_set_name, "new_products")
8595
self.assertEqual(info.metadata_id, "meta-guid-1")
96+
self.assertEqual(info.primary_name_attribute, "new_name")
97+
self.assertEqual(info.primary_id_attribute, "new_productid")
8698
self.assertEqual(info.columns_created, ["new_Price"])
8799

88100
def test_from_dict_missing_keys(self):
89101
info = TableInfo.from_dict({})
90102
self.assertEqual(info.schema_name, "")
103+
self.assertIsNone(info.primary_name_attribute)
104+
self.assertIsNone(info.primary_id_attribute)
91105
self.assertIsNone(info.columns_created)
92106

107+
def test_from_dict_legacy_access_primary_fields(self):
108+
"""Primary fields are accessible via legacy dict-key access."""
109+
data = {
110+
"table_schema_name": "new_Product",
111+
"table_logical_name": "new_product",
112+
"entity_set_name": "new_products",
113+
"metadata_id": "meta-guid-1",
114+
"primary_name_attribute": "new_name",
115+
"primary_id_attribute": "new_productid",
116+
"columns_created": [],
117+
}
118+
info = TableInfo.from_dict(data)
119+
self.assertEqual(info["primary_name_attribute"], "new_name")
120+
self.assertEqual(info["primary_id_attribute"], "new_productid")
121+
93122

94123
class TestTableInfoFromApiResponse(unittest.TestCase):
95124
"""Tests for TableInfo.from_api_response factory (PascalCase keys)."""
@@ -100,6 +129,8 @@ def test_from_api_response(self):
100129
"LogicalName": "account",
101130
"EntitySetName": "accounts",
102131
"MetadataId": "meta-guid-2",
132+
"PrimaryNameAttribute": "name",
133+
"PrimaryIdAttribute": "accountid",
103134
"DisplayName": {"UserLocalizedLabel": {"Label": "Account", "LanguageCode": 1033}},
104135
"Description": {"UserLocalizedLabel": {"Label": "Business account", "LanguageCode": 1033}},
105136
}
@@ -108,6 +139,8 @@ def test_from_api_response(self):
108139
self.assertEqual(info.logical_name, "account")
109140
self.assertEqual(info.entity_set_name, "accounts")
110141
self.assertEqual(info.metadata_id, "meta-guid-2")
142+
self.assertEqual(info.primary_name_attribute, "name")
143+
self.assertEqual(info.primary_id_attribute, "accountid")
111144
self.assertEqual(info.display_name, "Account")
112145
self.assertEqual(info.description, "Business account")
113146

0 commit comments

Comments
 (0)