Skip to content

Commit f96e1a1

Browse files
author
Samson Gebre
committed
Refactor SQL query handling and error management in Dataverse SDK
- Updated SQL query examples to use correct field names for ownerid and related entities. - Removed unsupported SQL validation error code. - Enhanced parameter handling for OneToMany and ManyToMany relationships. - Improved test coverage for metadata error handling in entity set resolution. - Fix integration tests
1 parent f499a30 commit f96e1a1

8 files changed

Lines changed: 86 additions & 61 deletions

File tree

examples/advanced/relationships.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def delete_relationship_if_exists(client, schema_name):
4242
"""Delete a relationship by schema name if it exists."""
4343
rel = client.tables.get_relationship(schema_name)
4444
if rel:
45-
rel_id = rel.get("MetadataId")
45+
rel_id = rel.relationship_id
4646
if rel_id:
4747
client.tables.delete_relationship(rel_id)
4848
print(f" (Cleaned up existing relationship: {schema_name})")
@@ -236,11 +236,11 @@ def _run_example(client):
236236
)
237237
)
238238

239-
print(f"[OK] Created relationship: {result['relationship_schema_name']}")
240-
print(f" Lookup field: {result['lookup_schema_name']}")
241-
print(f" Relationship ID: {result['relationship_id']}")
239+
print(f"[OK] Created relationship: {result.relationship_schema_name}")
240+
print(f" Lookup field: {result.lookup_schema_name}")
241+
print(f" Relationship ID: {result.relationship_id}")
242242

243-
rel_id_1 = result["relationship_id"]
243+
rel_id_1 = result.relationship_id
244244

245245
# ============================================================================
246246
# 5. CREATE LOOKUP FIELD (Convenience Method)
@@ -265,10 +265,10 @@ def _run_example(client):
265265
)
266266
)
267267

268-
print(f"[OK] Created lookup using convenience method: {result2['lookup_schema_name']}")
269-
print(f" Relationship: {result2['relationship_schema_name']}")
268+
print(f"[OK] Created lookup using convenience method: {result2.lookup_schema_name}")
269+
print(f" Relationship: {result2.relationship_schema_name}")
270270

271-
rel_id_2 = result2["relationship_id"]
271+
rel_id_2 = result2.relationship_id
272272

273273
# ============================================================================
274274
# 6. CREATE MANY-TO-MANY RELATIONSHIP
@@ -292,10 +292,10 @@ def _run_example(client):
292292
)
293293
)
294294

295-
print(f"[OK] Created M:N relationship: {result3['relationship_schema_name']}")
296-
print(f" Relationship ID: {result3['relationship_id']}")
295+
print(f"[OK] Created M:N relationship: {result3.relationship_schema_name}")
296+
print(f" Relationship ID: {result3.relationship_id}")
297297

298-
rel_id_3 = result3["relationship_id"]
298+
rel_id_3 = result3.relationship_id
299299

300300
# ============================================================================
301301
# 7. QUERY RELATIONSHIP METADATA
@@ -308,21 +308,21 @@ def _run_example(client):
308308

309309
rel_metadata = client.tables.get_relationship("new_Department_Employee")
310310
if rel_metadata:
311-
print(f"[OK] Found relationship: {rel_metadata.get('SchemaName')}")
312-
print(f" Type: {rel_metadata.get('@odata.type')}")
313-
print(f" Referenced Entity: {rel_metadata.get('ReferencedEntity')}")
314-
print(f" Referencing Entity: {rel_metadata.get('ReferencingEntity')}")
311+
print(f"[OK] Found relationship: {rel_metadata.relationship_schema_name}")
312+
print(f" Type: {rel_metadata.relationship_type}")
313+
print(f" Referenced Entity: {rel_metadata.referenced_entity}")
314+
print(f" Referencing Entity: {rel_metadata.referencing_entity}")
315315
else:
316316
print(" Relationship not found")
317317

318318
log_call("Retrieving M:N relationship by schema name")
319319

320320
m2m_metadata = client.tables.get_relationship("new_employee_project")
321321
if m2m_metadata:
322-
print(f"[OK] Found relationship: {m2m_metadata.get('SchemaName')}")
323-
print(f" Type: {m2m_metadata.get('@odata.type')}")
324-
print(f" Entity 1: {m2m_metadata.get('Entity1LogicalName')}")
325-
print(f" Entity 2: {m2m_metadata.get('Entity2LogicalName')}")
322+
print(f"[OK] Found relationship: {m2m_metadata.relationship_schema_name}")
323+
print(f" Type: {m2m_metadata.relationship_type}")
324+
print(f" Entity 1: {m2m_metadata.entity1_logical_name}")
325+
print(f" Entity 2: {m2m_metadata.entity2_logical_name}")
326326
else:
327327
print(" Relationship not found")
328328

examples/advanced/sql_examples.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ def _run_examples(client):
612612
lookup_names = sorted(
613613
c.get("LogicalName", "")
614614
for c in acct_cols
615-
if c.get("LogicalName", "").startswith("_") and c.get("LogicalName", "").endswith("_value")
615+
if c.get("LogicalName", "")
616616
)
617617
print(f"[OK] Lookup columns on account ({len(lookup_names)} found):")
618618
for ln in lookup_names[:10]:
@@ -626,12 +626,7 @@ def _run_examples(client):
626626
print("\n-- 23b. Discover which entities a polymorphic lookup targets --")
627627
log_call("client.tables.list_table_relationships('account', ...)")
628628
try:
629-
acct_rels = backoff(
630-
lambda: client.tables.list_table_relationships(
631-
"account",
632-
select=["SchemaName", "ReferencedEntity", "ReferencingEntity", "ReferencingAttribute"],
633-
)
634-
)
629+
acct_rels = backoff(lambda: client.tables.list_table_relationships("account"))
635630
by_attr = defaultdict(list)
636631
for rel in acct_rels:
637632
attr = rel.get("ReferencingAttribute", "")
@@ -650,23 +645,23 @@ def _run_examples(client):
650645
print("ownerid is polymorphic (systemuser or team). Use separate\n" "JOINs and combine in a DataFrame.")
651646
try:
652647
# Records owned by users
653-
log_call("SQL: account JOIN systemuser ON _ownerid_value")
648+
log_call("SQL: account JOIN systemuser ON ownerid")
654649
df_user_owned = backoff(
655650
lambda: client.dataframe.sql(
656651
"SELECT TOP 5 a.name, su.fullname as owner_name "
657652
"FROM account a "
658-
"INNER JOIN systemuser su ON a._ownerid_value = su.systemuserid"
653+
"INNER JOIN systemuser su ON a.ownerid = su.systemuserid"
659654
)
660655
)
661656
df_user_owned["owner_type"] = "User"
662657

663658
# Records owned by teams
664-
log_call("SQL: account JOIN team ON _ownerid_value")
659+
log_call("SQL: account JOIN team ON ownerid")
665660
df_team_owned = backoff(
666661
lambda: client.dataframe.sql(
667662
"SELECT TOP 5 a.name, t.name as owner_name "
668663
"FROM account a "
669-
"INNER JOIN team t ON a._ownerid_value = t.teamid"
664+
"INNER JOIN team t ON a.ownerid = t.teamid"
670665
)
671666
)
672667
df_team_owned["owner_type"] = "Team"
@@ -690,8 +685,8 @@ def _run_examples(client):
690685
"creator.fullname as created_by, "
691686
"modifier.fullname as modified_by "
692687
"FROM account a "
693-
"JOIN systemuser creator ON a._createdby_value = creator.systemuserid "
694-
"JOIN systemuser modifier ON a._modifiedby_value = modifier.systemuserid"
688+
"JOIN systemuser creator ON a.createdby = creator.systemuserid "
689+
"JOIN systemuser modifier ON a.modifiedby = modifier.systemuserid"
695690
)
696691
)
697692
print(f"[OK] Audit trail: {len(results)} rows")

src/PowerPlatform/Dataverse/core/_error_codes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
VALIDATION_SQL_NOT_STRING = "validation_sql_not_string"
4545
VALIDATION_SQL_EMPTY = "validation_sql_empty"
4646
VALIDATION_SQL_WRITE_BLOCKED = "validation_sql_write_blocked"
47-
VALIDATION_SQL_CROSS_JOIN_BLOCKED = "validation_sql_cross_join_blocked"
4847
VALIDATION_SQL_UNSUPPORTED_SYNTAX = "validation_sql_unsupported_syntax"
4948
VALIDATION_ENUM_NO_MEMBERS = "validation_enum_no_members"
5049
VALIDATION_ENUM_NON_INT_VALUE = "validation_enum_non_int_value"

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -976,20 +976,9 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
976976
raise ValidationError("sql must be a non-empty string", subcode=VALIDATION_SQL_EMPTY)
977977
sql = sql.strip()
978978

979-
# Block write statements FIRST (before table extraction, since
980-
# UPDATE/INSERT/DELETE don't have FROM clauses).
981-
# Strip SQL comments to catch e.g. /**/DELETE or --\\nDELETE.
982-
sql_no_comments = self._SQL_COMMENT_RE.sub(" ", sql).strip()
983-
if self._SQL_WRITE_RE.search(sql_no_comments):
984-
raise ValidationError(
985-
"SQL endpoint is read-only. Use client.records or "
986-
"client.dataframe for write operations "
987-
"(INSERT/UPDATE/DELETE are not supported).",
988-
subcode=VALIDATION_SQL_WRITE_BLOCKED,
989-
)
990-
991-
# Apply safety guardrails (block unsupported syntax, warn on risky patterns).
992-
# SELECT * raises ValidationError here before any table resolution.
979+
# Apply safety guardrails (block unsupported syntax including writes,
980+
# warn on risky patterns). SELECT * raises ValidationError here before
981+
# any table resolution.
993982
sql = self._sql_guardrails(sql)
994983

995984
r = self._execute_raw(self._build_sql(sql))

src/PowerPlatform/Dataverse/data/_relationships.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,19 +218,28 @@ def _list_table_relationships(
218218
)
219219

220220
metadata_id = ent["MetadataId"]
221-
params: Dict[str, str] = {}
221+
# OneToMany/ManyToOne share the same property surface (ReferencedEntity,
222+
# ReferencingEntity, etc.). ManyToManyRelationshipMetadata has a
223+
# different schema -- it only exposes SchemaName plus Entity1/Entity2
224+
# fields, not ReferencedEntity or ReferencingEntity. Sending a $select
225+
# that includes those properties to the ManyToMany endpoint causes a
226+
# 400: "Could not find a property named 'ReferencedEntity' on type
227+
# 'ManyToManyRelationshipMetadata'". Use separate param dicts.
228+
one_to_many_params: Dict[str, str] = {}
229+
many_to_many_params: Dict[str, str] = {}
222230
if filter:
223-
params["$filter"] = filter
231+
one_to_many_params["$filter"] = filter
232+
many_to_many_params["$filter"] = filter
224233
if select:
225-
params["$select"] = ",".join(select)
234+
one_to_many_params["$select"] = ",".join(select)
226235

227236
one_to_many_url = f"{self.api}/EntityDefinitions({metadata_id})/OneToManyRelationships"
228237
many_to_one_url = f"{self.api}/EntityDefinitions({metadata_id})/ManyToOneRelationships"
229238
many_to_many_url = f"{self.api}/EntityDefinitions({metadata_id})/ManyToManyRelationships"
230239

231-
r1 = self._request("get", one_to_many_url, headers=self._headers(), params=params)
232-
r2 = self._request("get", many_to_one_url, headers=self._headers(), params=params)
233-
r3 = self._request("get", many_to_many_url, headers=self._headers(), params=params)
240+
r1 = self._request("get", one_to_many_url, headers=self._headers(), params=one_to_many_params)
241+
r2 = self._request("get", many_to_one_url, headers=self._headers(), params=one_to_many_params)
242+
r3 = self._request("get", many_to_many_url, headers=self._headers(), params=many_to_many_params)
234243

235244
return r1.json().get("value", []) + r2.json().get("value", []) + r3.json().get("value", [])
236245

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
from typing import Any, Dict, List, Optional, TYPE_CHECKING
99

10+
from ..core.errors import MetadataError
1011
from ..models.record import Record
11-
1212
from ..models.query_builder import QueryBuilder
1313

1414
if TYPE_CHECKING:
@@ -484,7 +484,7 @@ def odata_expands(
484484
try:
485485
with self._client._scoped_odata() as od:
486486
target_set = od._entity_set_from_schema_name(target)
487-
except (KeyError, AttributeError, ValueError):
487+
except (KeyError, AttributeError, ValueError, MetadataError):
488488
pass # Entity set resolution failed; target_set stays empty
489489

490490
result.append(

tests/unit/data/test_relationships.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -478,20 +478,30 @@ def test_filter_param_is_forwarded(self):
478478
params = call[1].get("params", {})
479479
self.assertEqual(params["$filter"], "IsManaged eq false")
480480

481-
def test_select_param_is_forwarded(self):
482-
"""Test that $select is sent to all three sub-requests."""
481+
def test_select_param_forwarded_to_one_to_many_only(self):
482+
"""$select is sent to OneToMany and ManyToOne but NOT ManyToMany.
483+
484+
ManyToManyRelationshipMetadata has a different property surface --
485+
it does not expose ReferencedEntity or ReferencingEntity. Sending a
486+
$select with those names to the ManyToMany endpoint causes a 400 from
487+
the server.
488+
"""
483489
self.client._mock_request.side_effect = [
484490
self._make_response([]),
485491
self._make_response([]),
486492
self._make_response([]),
487493
]
488494

489-
self.client._list_table_relationships("account", select=["SchemaName", "MetadataId"])
495+
self.client._list_table_relationships("account", select=["SchemaName", "ReferencedEntity"])
490496

491497
calls = self.client._mock_request.call_args_list
492-
for call in calls:
493-
params = call[1].get("params", {})
494-
self.assertEqual(params["$select"], "SchemaName,MetadataId")
498+
self.assertEqual(len(calls), 3)
499+
one_to_many_params = calls[0][1].get("params", {})
500+
many_to_one_params = calls[1][1].get("params", {})
501+
many_to_many_params = calls[2][1].get("params", {})
502+
self.assertEqual(one_to_many_params["$select"], "SchemaName,ReferencedEntity")
503+
self.assertEqual(many_to_one_params["$select"], "SchemaName,ReferencedEntity")
504+
self.assertNotIn("$select", many_to_many_params)
495505

496506
def test_raises_metadata_error_when_table_not_found(self):
497507
"""Test that MetadataError is raised when entity is not found."""

tests/unit/test_query_operations.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from azure.core.credentials import TokenCredential
88

99
from PowerPlatform.Dataverse.client import DataverseClient
10+
from PowerPlatform.Dataverse.core.errors import MetadataError
1011
from PowerPlatform.Dataverse.models.record import Record
1112
from PowerPlatform.Dataverse.operations.query import QueryOperations
1213

@@ -837,6 +838,28 @@ def test_polymorphic_returns_multiple(self):
837838
nav_props = {e["nav_property"] for e in expands}
838839
self.assertEqual(nav_props, {"customerid_account", "customerid_contact"})
839840

841+
def test_metadata_error_on_entity_set_resolution_is_swallowed(self):
842+
"""MetadataError from entity-set resolution must not propagate -- target_entity_set stays empty."""
843+
self._mock_rels(
844+
[
845+
{
846+
"ReferencingEntity": "contact",
847+
"ReferencingAttribute": "parentcustomerid",
848+
"ReferencedEntity": "account",
849+
"ReferencedAttribute": "accountid",
850+
"ReferencingEntityNavigationPropertyName": "parentcustomerid_account",
851+
"SchemaName": "contact_customer_accounts",
852+
},
853+
]
854+
)
855+
self.client._odata._entity_set_from_schema_name.side_effect = MetadataError(
856+
"Unable to resolve entity set for 'account'.",
857+
subcode="metadata_entityset_not_found",
858+
)
859+
expands = self.client.query.odata_expands("contact")
860+
self.assertEqual(len(expands), 1)
861+
self.assertEqual(expands[0]["target_entity_set"], "")
862+
840863

841864
class TestOdataExpand(unittest.TestCase):
842865
"""Tests for client.query.odata_expand()."""

0 commit comments

Comments
 (0)