Skip to content

Commit 65aac7d

Browse files
author
Samson Gebre
committed
feat: Add support for $expand in record retrieval methods and update documentation
1 parent b7b4a8b commit 65aac7d

11 files changed

Lines changed: 144 additions & 18 deletions

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ contact_ids = client.records.create("contact", contacts)
9999
# Get single record by ID
100100
account = client.records.retrieve("account", account_id, select=["name", "telephone1"])
101101

102+
# With expand — fetch a related record in the same HTTP request
103+
account = client.records.retrieve(
104+
"account", account_id,
105+
select=["name"],
106+
expand=["primarycontactid"],
107+
)
108+
contact = (account.get("primarycontactid") or {})
109+
print(contact.get("fullname"))
110+
102111
# Simple shortcut — use records.list() only for basic filter + select without composable logic.
103112
# Follows @odata.nextLink automatically and loads all matching records into memory.
104113
# For filtering, sorting, expansion, or formatted values, prefer client.query.builder() (see below).
@@ -481,7 +490,7 @@ Use `client.batch` to send multiple operations in one HTTP request. All batch me
481490
batch = client.batch.new()
482491
batch.records.create("account", {"name": "Contoso"})
483492
batch.records.update("account", account_id, {"telephone1": "555-0100"})
484-
batch.records.retrieve("account", account_id, select=["name"], include_annotations="OData.Community.Display.V1.FormattedValue") # single record
493+
batch.records.retrieve("account", account_id, select=["name"], expand=["primarycontactid"], include_annotations="OData.Community.Display.V1.FormattedValue") # single record with expand
485494
batch.records.list("account", filter="statecode eq 0", select=["name"], orderby=["name asc"], top=50, page_size=25, count=True) # multi-record, single page
486495
batch.query.sql("SELECT TOP 5 name FROM account")
487496

CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- `client.records.retrieve(table, record_id, *, select, include_annotations)` — fetch a single record by GUID; returns `None` on 404 instead of raising; `include_annotations` maps to the `Prefer: odata.include-annotations` header for formatted values and lookup labels (#175)
11+
- `client.records.retrieve(table, record_id, *, select, expand, include_annotations)` — fetch a single record by GUID; returns `None` on 404 instead of raising; `expand` adds `$expand` for navigation property expansion on the single-record GET; `include_annotations` maps to the `Prefer: odata.include-annotations` header for formatted values and lookup labels (#175)
1212
- `client.records.list(table, *, filter, select, top, orderby, expand, page_size, count, include_annotations)` — eager fetch returning a flat `QueryResult`; GA replacement for `records.get()` without a record ID; `page_size` controls `Prefer: odata.maxpagesize`, `count=True` adds `$count=true`, `include_annotations` requests formatted values (#175)
1313
- `client.records.list_pages(table, *, filter, select, top, orderby, expand, page_size, count, include_annotations)` — lazy iterator yielding one `QueryResult` per HTTP page; streaming counterpart to `list()`; same parameter set (#175)
14-
- `client.batch.records.retrieve()` and `client.batch.records.list()` now accept the same `include_annotations`, `orderby`, `expand`, `page_size`, and `count` parameters as their non-batch counterparts (#175)
1514
- `client.query.fetchxml(xml)` — FetchXML support returning an inert `FetchXmlQuery`; no HTTP request is made until `.execute()` or `.execute_pages()` is called (#175)
1615
- `FetchXmlQuery` implements the correct Dataverse paging cookie algorithm: annotation parsed as outer XML, `pagingcookie` attribute double URL-decoded, server-supplied `pagenumber` used for next page, `morerecords` handled as both `bool` and `"true"` string, `UserWarning` emitted on simple paging fallback, 32,768-character URL limit enforced (documented Dataverse GET cap), 10,000-page circuit breaker against runaway iteration (#175)
1716
- `QueryBuilder.execute_pages()` — lazy per-page streaming returning one `QueryResult` per HTTP page; replaces deprecated `execute(by_page=True)` (#175)

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ account_id = client.records.create("account", {"name": "Contoso Ltd"})
163163
account = client.records.retrieve("account", account_id)
164164
print(account["name"])
165165

166+
# Read with expand — fetch a related record in the same HTTP request
167+
account = client.records.retrieve(
168+
"account", account_id,
169+
select=["name"],
170+
expand=["primarycontactid"],
171+
)
172+
contact = (account.get("primarycontactid") or {})
173+
print(contact.get("fullname"))
174+
166175
# Update a record
167176
client.records.update("account", account_id, {"telephone1": "555-0199"})
168177

@@ -694,7 +703,7 @@ batch.records.create("account", {"name": "Contoso"})
694703
batch.records.create("account", [{"name": "Fabrikam"}, {"name": "Woodgrove"}])
695704
batch.records.update("account", account_id, {"telephone1": "555-0100"})
696705
batch.records.delete("account", old_id)
697-
batch.records.retrieve("account", account_id, select=["name"]) # single record
706+
batch.records.retrieve("account", account_id, select=["name"], expand=["primarycontactid"]) # single record with expand
698707
batch.records.list( # multi-record, single page
699708
"account",
700709
filter="statecode eq 0",

examples/basic/functional_testing.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,21 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record
276276
else:
277277
print(f"[WARN] include_annotations: expected key '{ann_key}' not present in response")
278278

279+
# -- expand: verify navigation property expansion on a single-record GET --
280+
# owninguser is a system navigation property present on all user-owned tables.
281+
try:
282+
expanded = client.records.retrieve(
283+
table_schema_name,
284+
record_id,
285+
select=[f"{attr_prefix}_name"],
286+
expand=["owninguser"],
287+
)
288+
owner = (expanded.get("owninguser") or {}) if expanded else {}
289+
owner_name = owner.get("fullname") or owner.get("domainname") or "(unknown)"
290+
print(f"[OK] records.retrieve with expand=['owninguser']: owner='{owner_name}'")
291+
except Exception as e: # noqa: BLE001
292+
print(f"[WARN] records.retrieve expand skipped: {e}")
293+
279294
return record
280295

281296
except HttpError as e:
@@ -597,11 +612,12 @@ def test_batch_all_operations(client: DataverseClient, table_info: Dict[str, Any
597612
" + tables.get + tables.list + query.sql (5 ops, 1 POST $batch)"
598613
)
599614
batch = client.batch.new()
600-
# [0] Single-record retrieve with annotations
615+
# [0] Single-record retrieve with annotations and expand
601616
batch.records.retrieve(
602617
table_schema_name,
603618
all_ids[0],
604619
select=[f"{attr_prefix}_name", f"{attr_prefix}_count", f"{attr_prefix}_is_active"],
620+
expand=["owninguser"],
605621
include_annotations=annotation,
606622
)
607623
# [1] Multi-record list with orderby, page_size, count, include_annotations
@@ -630,7 +646,10 @@ def test_batch_all_operations(client: DataverseClient, table_info: Dict[str, Any
630646
name = resp.data.get(f"{attr_prefix}_name")
631647
ann_key = f"{attr_prefix}_is_active@{annotation}"
632648
ann_val = resp.data.get(ann_key, "<not returned>")
649+
owner = resp.data.get("owninguser") or {}
650+
owner_name = owner.get("fullname") or owner.get("domainname") or "<not returned>"
633651
print(f" records.retrieve → name='{name}', {ann_key}='{ann_val}'")
652+
print(f" records.retrieve expand=['owninguser'] → owner='{owner_name}'")
634653
elif i == 1 and resp.data:
635654
rows = resp.data.get("value", [])
636655
names_ordered = [r.get(f"{attr_prefix}_name") for r in rows]

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ contact_ids = client.records.create("contact", contacts)
9999
# Get single record by ID
100100
account = client.records.retrieve("account", account_id, select=["name", "telephone1"])
101101

102+
# With expand — fetch a related record in the same HTTP request
103+
account = client.records.retrieve(
104+
"account", account_id,
105+
select=["name"],
106+
expand=["primarycontactid"],
107+
)
108+
contact = (account.get("primarycontactid") or {})
109+
print(contact.get("fullname"))
110+
102111
# Simple shortcut — use records.list() only for basic filter + select without composable logic.
103112
# Follows @odata.nextLink automatically and loads all matching records into memory.
104113
# For filtering, sorting, expansion, or formatted values, prefer client.query.builder() (see below).
@@ -481,7 +490,7 @@ Use `client.batch` to send multiple operations in one HTTP request. All batch me
481490
batch = client.batch.new()
482491
batch.records.create("account", {"name": "Contoso"})
483492
batch.records.update("account", account_id, {"telephone1": "555-0100"})
484-
batch.records.retrieve("account", account_id, select=["name"], include_annotations="OData.Community.Display.V1.FormattedValue") # single record
493+
batch.records.retrieve("account", account_id, select=["name"], expand=["primarycontactid"], include_annotations="OData.Community.Display.V1.FormattedValue") # single record with expand
485494
batch.records.list("account", filter="statecode eq 0", select=["name"], orderby=["name asc"], top=50, page_size=25, count=True) # multi-record, single page
486495
batch.query.sql("SELECT TOP 5 name FROM account")
487496

src/PowerPlatform/Dataverse/data/_batch.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class _RecordGet:
6969
table: str
7070
record_id: str
7171
select: Optional[List[str]] = None
72+
expand: Optional[List[str]] = None
7273
include_annotations: Optional[str] = None
7374

7475

@@ -399,7 +400,13 @@ def _resolve_record_delete(self, op: _RecordDelete) -> List[_RawRequest]:
399400

400401
def _resolve_record_get(self, op: _RecordGet) -> List[_RawRequest]:
401402
return [
402-
self._od._build_get(op.table, op.record_id, select=op.select, include_annotations=op.include_annotations)
403+
self._od._build_get(
404+
op.table,
405+
op.record_id,
406+
select=op.select,
407+
expand=op.expand,
408+
include_annotations=op.include_annotations,
409+
)
403410
]
404411

405412
def _resolve_record_list(self, op: _RecordList) -> List[_RawRequest]:

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,7 @@ def _get(
689689
table_schema_name: str,
690690
key: str,
691691
select: Optional[List[str]] = None,
692+
expand: Optional[List[str]] = None,
692693
include_annotations: Optional[str] = None,
693694
) -> Dict[str, Any]:
694695
"""Retrieve a single record.
@@ -699,14 +700,18 @@ def _get(
699700
:type key: ``str``
700701
:param select: Columns to select; joined with commas into $select.
701702
:type select: ``list[str]`` | ``None``
703+
:param expand: Navigation properties to expand (``$expand``); passed as-is (case-sensitive).
704+
:type expand: ``list[str]`` | ``None``
702705
:param include_annotations: OData annotation pattern for the ``Prefer: odata.include-annotations`` header, or ``None``.
703706
:type include_annotations: ``str`` | ``None``
704707
705708
:return: Retrieved record dictionary (may be empty if no selected attributes).
706709
:rtype: ``dict[str, Any]``
707710
"""
708711
return self._execute_raw(
709-
self._build_get(table_schema_name, key, select=select, include_annotations=include_annotations)
712+
self._build_get(
713+
table_schema_name, key, select=select, expand=expand, include_annotations=include_annotations
714+
)
710715
).json()
711716

712717
def _get_multiple(
@@ -2319,13 +2324,19 @@ def _build_get(
23192324
record_id: str,
23202325
*,
23212326
select: Optional[List[str]] = None,
2327+
expand: Optional[List[str]] = None,
23222328
include_annotations: Optional[str] = None,
23232329
) -> _RawRequest:
23242330
"""Build a single-record GET request without sending it."""
23252331
entity_set = self._entity_set_from_schema_name(table)
2326-
url = f"{self.api}/{entity_set}{self._format_key(record_id)}"
2332+
params: List[str] = []
23272333
if select:
2328-
url += "?$select=" + ",".join(self._lowercase_list(select))
2334+
params.append("$select=" + ",".join(self._lowercase_list(select)))
2335+
if expand:
2336+
params.append("$expand=" + ",".join(expand))
2337+
url = f"{self.api}/{entity_set}{self._format_key(record_id)}"
2338+
if params:
2339+
url += "?" + "&".join(params)
23292340
headers = None
23302341
if include_annotations:
23312342
headers = {"Prefer": f'odata.include-annotations="{include_annotations}"'}

src/PowerPlatform/Dataverse/operations/batch.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ def retrieve(
335335
record_id: str,
336336
*,
337337
select: Optional[List[str]] = None,
338+
expand: Optional[List[str]] = None,
338339
include_annotations: Optional[str] = None,
339340
) -> None:
340341
"""
@@ -351,6 +352,10 @@ def retrieve(
351352
:type record_id: :class:`str`
352353
:param select: Optional list of column logical names to include.
353354
:type select: list[str] or None
355+
:param expand: Optional list of navigation properties to expand.
356+
Navigation property names are case-sensitive and must match the
357+
entity's ``$metadata``.
358+
:type expand: list[str] or None
354359
:param include_annotations: OData annotation pattern for the
355360
``Prefer: odata.include-annotations`` header (e.g. ``"*"`` or
356361
``"OData.Community.Display.V1.FormattedValue"``), or ``None``.
@@ -362,14 +367,22 @@ def retrieve(
362367
batch.records.retrieve(
363368
"account", account_id,
364369
select=["name", "statuscode"],
370+
expand=["primarycontactid"],
365371
include_annotations="OData.Community.Display.V1.FormattedValue",
366372
)
367373
result = batch.execute()
368374
record = result.responses[0].data
369-
print(record["statuscode@OData.Community.Display.V1.FormattedValue"])
375+
contact = (record.get("primarycontactid") or {})
376+
print(contact.get("fullname"))
370377
"""
371378
self._batch._items.append(
372-
_RecordGet(table=table, record_id=record_id, select=select, include_annotations=include_annotations)
379+
_RecordGet(
380+
table=table,
381+
record_id=record_id,
382+
select=select,
383+
expand=expand,
384+
include_annotations=include_annotations,
385+
)
373386
)
374387

375388
def list(

src/PowerPlatform/Dataverse/operations/records.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ def retrieve(
487487
record_id: str,
488488
*,
489489
select: Optional[List[str]] = None,
490+
expand: Optional[List[str]] = None,
490491
include_annotations: Optional[str] = None,
491492
) -> Optional[Record]:
492493
"""Fetch a single record by its GUID, returning ``None`` if not found.
@@ -500,6 +501,10 @@ def retrieve(
500501
:type record_id: :class:`str`
501502
:param select: Optional list of column logical names to include.
502503
:type select: list[str] or None
504+
:param expand: Optional list of navigation properties to expand (e.g.
505+
``["primarycontactid"]``). Navigation property names are
506+
case-sensitive and must match the entity's ``$metadata``.
507+
:type expand: list[str] or None
503508
:param include_annotations: OData annotation pattern for the
504509
``Prefer: odata.include-annotations`` header (e.g. ``"*"`` or
505510
``"OData.Community.Display.V1.FormattedValue"``), or ``None``.
@@ -512,14 +517,16 @@ def retrieve(
512517
record = client.records.retrieve(
513518
"account", account_id,
514519
select=["name", "statuscode"],
520+
expand=["primarycontactid"],
515521
include_annotations="OData.Community.Display.V1.FormattedValue",
516522
)
517523
if record is not None:
518-
print(record["statuscode@OData.Community.Display.V1.FormattedValue"])
524+
contact = record.get("primarycontactid") or {}
525+
print(contact.get("fullname"))
519526
"""
520527
with self._client._scoped_odata() as od:
521528
try:
522-
raw = od._get(table, record_id, select=select, include_annotations=include_annotations)
529+
raw = od._get(table, record_id, select=select, expand=expand, include_annotations=include_annotations)
523530
except HttpError as exc:
524531
if exc.status_code == 404:
525532
return None

tests/unit/data/test_batch_serialization.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,22 @@ def test_resolve_record_get(self):
253253
op = _RecordGet(table="account", record_id="guid-1", select=["name"])
254254
result = client._resolve_record_get(op)
255255

256-
od._build_get.assert_called_once_with("account", "guid-1", select=["name"], include_annotations=None)
256+
od._build_get.assert_called_once_with(
257+
"account", "guid-1", select=["name"], expand=None, include_annotations=None
258+
)
259+
self.assertEqual(result, [mock_req])
260+
261+
def test_resolve_record_get_with_expand(self):
262+
client, od = self._client_and_od()
263+
mock_req = MagicMock()
264+
od._build_get.return_value = mock_req
265+
266+
op = _RecordGet(table="account", record_id="guid-1", select=["name"], expand=["primarycontactid"])
267+
result = client._resolve_record_get(op)
268+
269+
od._build_get.assert_called_once_with(
270+
"account", "guid-1", select=["name"], expand=["primarycontactid"], include_annotations=None
271+
)
257272
self.assertEqual(result, [mock_req])
258273

259274
def test_resolve_record_get_with_annotations(self):
@@ -265,7 +280,9 @@ def test_resolve_record_get_with_annotations(self):
265280
op = _RecordGet(table="account", record_id="guid-1", select=["name"], include_annotations=annotation)
266281
result = client._resolve_record_get(op)
267282

268-
od._build_get.assert_called_once_with("account", "guid-1", select=["name"], include_annotations=annotation)
283+
od._build_get.assert_called_once_with(
284+
"account", "guid-1", select=["name"], expand=None, include_annotations=annotation
285+
)
269286
self.assertEqual(result, [mock_req])
270287

271288
def test_resolve_record_list_basic(self):

0 commit comments

Comments
 (0)