Skip to content

Commit 86f9c37

Browse files
author
Saurabh Badenkal
committed
Add OData helpers: odata_select, odata_expands, odata_expand, odata_bind
New methods on client.query for OData users (parallel to SQL helpers): - odata_select(table) -> list[str] for records.get(select=) - odata_expands(table) -> all navigation properties with entity sets - odata_expand(from, to) -> PascalCase nav property name for expand= - odata_bind(from, to, id) -> @odata.bind dict for create/update payloads These eliminate the most error-prone parts of OData queries: - No more guessing PascalCase navigation property names for - No more manually constructing @odata.bind with entity set names - Column discovery matches records.get(select=) format directly 11 new unit tests, 756 total passing.
1 parent 89adad3 commit 86f9c37

3 files changed

Lines changed: 455 additions & 1 deletion

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,28 @@ for j in joins:
420420
print(f"{j['column']:30s} -> {j['target']}.{j['target_pk']}")
421421
```
422422

423-
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string:
423+
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string. The SDK provides helpers to eliminate the most error-prone parts:
424424

425425
```python
426+
# Discover columns for $select (returns list ready for select= parameter)
427+
cols = client.query.odata_select("account")
428+
for page in client.records.get("account", select=cols, top=10):
429+
...
430+
431+
# Discover $expand navigation properties (auto-resolves PascalCase names)
432+
nav = client.query.odata_expand("contact", "account")
433+
# Returns: "parentcustomerid_account"
434+
for page in client.records.get("contact", select=["fullname"], expand=[nav], top=5):
435+
for r in page:
436+
acct = r.get(nav) or {}
437+
print(f"{r['fullname']} -> {acct.get('name')}")
438+
439+
# Build @odata.bind for lookup fields (no manual name construction)
440+
bind = client.query.odata_bind("contact", "account", account_id)
441+
# Returns: {"parentcustomerid_account@odata.bind": "/accounts(guid)"}
442+
client.records.create("contact", {"firstname": "Jane", **bind})
443+
444+
# Raw OData query with manual parameters
426445
for page in client.records.get(
427446
"account",
428447
select=["name"],

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,209 @@ def sql_join(
375375
src = from_alias or from_table.lower()
376376
tgt = to_alias or to_lower[0]
377377
return f"JOIN {to_lower} {tgt} " f"ON {src}.{j['column']} = {tgt}.{j['target_pk']}"
378+
379+
# ===========================================================
380+
# OData helpers -- eliminate friction for records.get() users
381+
# ===========================================================
382+
383+
# -------------------------------------------------------- odata_select
384+
385+
def odata_select(
386+
self,
387+
table: str,
388+
*,
389+
include_system: bool = False,
390+
) -> List[str]:
391+
"""Return a list of column logical names suitable for ``$select``.
392+
393+
Can be passed directly to ``client.records.get(table, select=...)``.
394+
395+
:param table: Schema name of the table (e.g. ``"account"``).
396+
:type table: :class:`str`
397+
:param include_system: Include system columns (default ``False``).
398+
:type include_system: :class:`bool`
399+
400+
:return: List of lowercase column logical names.
401+
:rtype: list[str]
402+
403+
Example::
404+
405+
cols = client.query.odata_select("account")
406+
for page in client.records.get("account", select=cols, top=10):
407+
for r in page:
408+
print(r)
409+
"""
410+
columns = self.sql_columns(table, include_system=include_system)
411+
return [c["name"] for c in columns]
412+
413+
# ------------------------------------------------------- odata_expands
414+
415+
def odata_expands(
416+
self,
417+
table: str,
418+
) -> List[Dict[str, Any]]:
419+
"""Discover all ``$expand`` navigation properties from a table.
420+
421+
Returns entries for each outgoing lookup (single-valued navigation
422+
property). Each entry contains the exact PascalCase navigation
423+
property name needed for ``$expand`` and ``@odata.bind``, plus
424+
the target entity set name.
425+
426+
:param table: Schema name of the table (e.g. ``"contact"``).
427+
:type table: :class:`str`
428+
429+
:return: List of dicts, each with:
430+
431+
- ``nav_property`` -- PascalCase navigation property for $expand
432+
- ``target_table`` -- target entity logical name
433+
- ``target_entity_set`` -- target entity set (for @odata.bind)
434+
- ``lookup_attribute`` -- the lookup column logical name
435+
- ``relationship`` -- relationship schema name
436+
437+
:rtype: list[dict[str, typing.Any]]
438+
439+
Example::
440+
441+
expands = client.query.odata_expands("contact")
442+
for e in expands:
443+
print(f"expand={e['nav_property']} -> {e['target_table']}")
444+
445+
# Use in a query
446+
e = next(e for e in expands if e['target_table'] == 'account')
447+
for page in client.records.get("contact",
448+
select=["fullname"],
449+
expand=[e['nav_property']]):
450+
...
451+
"""
452+
table_lower = table.lower()
453+
rels = self._client.tables.list_table_relationships(table)
454+
455+
result: List[Dict[str, Any]] = []
456+
for r in rels:
457+
ref_entity = (r.get("ReferencingEntity") or "").lower()
458+
if ref_entity != table_lower:
459+
continue
460+
nav_prop = r.get("ReferencingEntityNavigationPropertyName", "")
461+
target = r.get("ReferencedEntity", "")
462+
lookup_attr = r.get("ReferencingAttribute", "")
463+
schema = r.get("SchemaName", "")
464+
if not nav_prop or not target:
465+
continue
466+
467+
# Resolve entity set name for @odata.bind
468+
target_set = ""
469+
try:
470+
with self._client._scoped_odata() as od:
471+
target_set = od._entity_set_from_schema_name(target)
472+
except Exception:
473+
pass
474+
475+
result.append(
476+
{
477+
"nav_property": nav_prop,
478+
"target_table": target,
479+
"target_entity_set": target_set,
480+
"lookup_attribute": lookup_attr,
481+
"relationship": schema,
482+
}
483+
)
484+
485+
result.sort(key=lambda x: (x["target_table"], x["nav_property"]))
486+
return result
487+
488+
# -------------------------------------------------------- odata_expand
489+
490+
def odata_expand(
491+
self,
492+
from_table: str,
493+
to_table: str,
494+
) -> str:
495+
"""Return the navigation property name to ``$expand`` from one table to another.
496+
497+
Discovers via relationship metadata. Returns the exact PascalCase
498+
string for the ``expand=`` parameter.
499+
500+
:param from_table: Schema name of the source table (e.g. ``"contact"``).
501+
:type from_table: :class:`str`
502+
:param to_table: Schema name of the target table (e.g. ``"account"``).
503+
:type to_table: :class:`str`
504+
505+
:return: The navigation property name (PascalCase).
506+
:rtype: :class:`str`
507+
508+
:raises ValueError: If no navigation property found for the target.
509+
510+
Example::
511+
512+
nav = client.query.odata_expand("contact", "account")
513+
# Returns e.g. "parentcustomerid_account"
514+
for page in client.records.get("contact",
515+
select=["fullname"],
516+
expand=[nav],
517+
top=5):
518+
for r in page:
519+
acct = r.get(nav) or {}
520+
print(f"{r['fullname']} -> {acct.get('name', 'N/A')}")
521+
"""
522+
to_lower = to_table.lower()
523+
expands = self.odata_expands(from_table)
524+
match = [e for e in expands if e["target_table"].lower() == to_lower]
525+
if not match:
526+
raise ValueError(
527+
f"No navigation property found from '{from_table}' to "
528+
f"'{to_table}'. Use client.query.odata_expands('{from_table}') "
529+
f"to see available targets."
530+
)
531+
return match[0]["nav_property"]
532+
533+
# --------------------------------------------------------- odata_bind
534+
535+
def odata_bind(
536+
self,
537+
from_table: str,
538+
to_table: str,
539+
target_id: str,
540+
) -> Dict[str, str]:
541+
"""Build an ``@odata.bind`` entry for setting a lookup field.
542+
543+
Auto-discovers the navigation property name and entity set name
544+
from metadata. Returns a single-entry dict that can be merged
545+
into a create or update payload.
546+
547+
:param from_table: Schema name of the entity being created/updated.
548+
:type from_table: :class:`str`
549+
:param to_table: Schema name of the target entity the lookup points to.
550+
:type to_table: :class:`str`
551+
:param target_id: GUID of the target record.
552+
:type target_id: :class:`str`
553+
554+
:return: A dict like ``{"NavProp@odata.bind": "/entityset(guid)"}``.
555+
:rtype: dict[str, str]
556+
557+
:raises ValueError: If no relationship found between the tables.
558+
559+
Example::
560+
561+
# Instead of manually constructing:
562+
# {"parentcustomerid_account@odata.bind": "/accounts(guid)"}
563+
# Just do:
564+
bind = client.query.odata_bind("contact", "account", acct_id)
565+
client.records.create("contact", {
566+
"firstname": "Jane",
567+
"lastname": "Doe",
568+
**bind,
569+
})
570+
"""
571+
to_lower = to_table.lower()
572+
expands = self.odata_expands(from_table)
573+
match = [e for e in expands if e["target_table"].lower() == to_lower and e["target_entity_set"]]
574+
if not match:
575+
raise ValueError(
576+
f"No relationship found from '{from_table}' to '{to_table}'. "
577+
f"Use client.query.odata_expands('{from_table}') to see options."
578+
)
579+
580+
e = match[0]
581+
key = f"{e['nav_property']}@odata.bind"
582+
value = f"/{e['target_entity_set']}({target_id})"
583+
return {key: value}

0 commit comments

Comments
 (0)