Skip to content

Commit b848324

Browse files
Abel Milashclaude
andcommitted
Sync async implementation with GA API: retrieve, list, expand/annotations support
- Add retrieve() and list() and list_pages() to AsyncRecordOperations (GA replacements for deprecated get()) - Add expand and include_annotations parameters to _AsyncODataClient._get() and _build_get() to match sync _ODataClient - Add _build_list() to _AsyncODataClient for batch list support - Add _RecordList handling to _AsyncBatchClient._resolve_item() and _resolve_record_list(); fix _resolve_record_get to pass expand and include_annotations - Update test for _resolve_record_get to match new call signature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 54a409d commit b848324

4 files changed

Lines changed: 287 additions & 7 deletions

File tree

src/PowerPlatform/Dataverse/aio/data/_async_batch.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_RecordUpdate,
1919
_RecordDelete,
2020
_RecordGet,
21+
_RecordList,
2122
_RecordUpsert,
2223
_TableCreate,
2324
_TableDelete,
@@ -180,6 +181,8 @@ async def _resolve_item(self, item: Any) -> List[_RawRequest]:
180181
return await self._resolve_record_delete(item)
181182
if isinstance(item, _RecordGet):
182183
return await self._resolve_record_get(item)
184+
if isinstance(item, _RecordList):
185+
return await self._resolve_record_list(item)
183186
if isinstance(item, _RecordUpsert):
184187
return await self._resolve_record_upsert(item)
185188
if isinstance(item, _TableCreate):
@@ -253,7 +256,30 @@ async def _resolve_record_delete(self, op: _RecordDelete) -> List[_RawRequest]:
253256
return requests
254257

255258
async def _resolve_record_get(self, op: _RecordGet) -> List[_RawRequest]:
256-
return [await self._od._build_get(op.table, op.record_id, select=op.select)]
259+
return [
260+
await self._od._build_get(
261+
op.table,
262+
op.record_id,
263+
select=op.select,
264+
expand=op.expand,
265+
include_annotations=op.include_annotations,
266+
)
267+
]
268+
269+
async def _resolve_record_list(self, op: _RecordList) -> List[_RawRequest]:
270+
return [
271+
await self._od._build_list(
272+
op.table,
273+
select=op.select,
274+
filter=op.filter,
275+
orderby=op.orderby,
276+
top=op.top,
277+
expand=op.expand,
278+
page_size=op.page_size,
279+
count=op.count,
280+
include_annotations=op.include_annotations,
281+
)
282+
]
257283

258284
async def _resolve_record_upsert(self, op: _RecordUpsert) -> List[_RawRequest]:
259285
entity_set = await self._od._entity_set_from_schema_name(op.table)

src/PowerPlatform/Dataverse/aio/data/_async_odata.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,8 @@ async def _get(
533533
table_schema_name: str,
534534
key: str,
535535
select: Optional[List[str]] = None,
536+
expand: Optional[List[str]] = None,
537+
include_annotations: Optional[str] = None,
536538
) -> Dict[str, Any]:
537539
"""Retrieve a single record.
538540
@@ -542,11 +544,17 @@ async def _get(
542544
:type key: ``str``
543545
:param select: Columns to select; joined with commas into $select.
544546
:type select: ``list[str]`` | ``None``
547+
:param expand: Navigation properties to expand (``$expand``); passed as-is (case-sensitive).
548+
:type expand: ``list[str]`` | ``None``
549+
:param include_annotations: OData annotation pattern for the ``Prefer: odata.include-annotations`` header, or ``None``.
550+
:type include_annotations: ``str`` | ``None``
545551
546552
:return: Retrieved record dictionary (may be empty if no selected attributes).
547553
:rtype: ``dict[str, Any]``
548554
"""
549-
r = await self._execute_raw(await self._build_get(table_schema_name, key, select=select))
555+
r = await self._execute_raw(
556+
await self._build_get(table_schema_name, key, select=select, expand=expand, include_annotations=include_annotations)
557+
)
550558
return await r.json(content_type=None)
551559

552560
async def _get_multiple(
@@ -1748,13 +1756,64 @@ async def _build_get(
17481756
record_id: str,
17491757
*,
17501758
select: Optional[List[str]] = None,
1759+
expand: Optional[List[str]] = None,
1760+
include_annotations: Optional[str] = None,
17511761
) -> _RawRequest:
17521762
"""Build a single-record GET request without sending it."""
17531763
entity_set = await self._entity_set_from_schema_name(table)
1764+
params: List[str] = []
1765+
if select:
1766+
params.append("$select=" + ",".join(self._lowercase_list(select)))
1767+
if expand:
1768+
params.append("$expand=" + ",".join(expand))
17541769
url = f"{self.api}/{entity_set}{self._format_key(record_id)}"
1770+
if params:
1771+
url += "?" + "&".join(params)
1772+
headers = None
1773+
if include_annotations:
1774+
headers = {"Prefer": f'odata.include-annotations="{include_annotations}"'}
1775+
return _RawRequest(method="GET", url=url, headers=headers)
1776+
1777+
async def _build_list(
1778+
self,
1779+
table: str,
1780+
*,
1781+
select: Optional[List[str]] = None,
1782+
filter: Optional[str] = None,
1783+
orderby: Optional[List[str]] = None,
1784+
top: Optional[int] = None,
1785+
expand: Optional[List[str]] = None,
1786+
page_size: Optional[int] = None,
1787+
count: bool = False,
1788+
include_annotations: Optional[str] = None,
1789+
) -> _RawRequest:
1790+
"""Build a multi-record GET request (single page, no pagination) without sending it."""
1791+
entity_set = await self._entity_set_from_schema_name(table)
1792+
params: List[str] = []
17551793
if select:
1756-
url += "?$select=" + ",".join(self._lowercase_list(select))
1757-
return _RawRequest(method="GET", url=url)
1794+
params.append("$select=" + ",".join(self._lowercase_list(select)))
1795+
if filter:
1796+
params.append("$filter=" + filter)
1797+
if orderby:
1798+
params.append("$orderby=" + ",".join(orderby))
1799+
if top is not None:
1800+
params.append(f"$top={top}")
1801+
if expand:
1802+
params.append("$expand=" + ",".join(expand))
1803+
if count:
1804+
params.append("$count=true")
1805+
url = f"{self.api}/{entity_set}"
1806+
if params:
1807+
url += "?" + "&".join(params)
1808+
prefer_parts: List[str] = []
1809+
if page_size is not None:
1810+
ps = int(page_size)
1811+
if ps > 0:
1812+
prefer_parts.append(f"odata.maxpagesize={ps}")
1813+
if include_annotations:
1814+
prefer_parts.append(f'odata.include-annotations="{include_annotations}"')
1815+
headers = {"Prefer": ",".join(prefer_parts)} if prefer_parts else None
1816+
return _RawRequest(method="GET", url=url, headers=headers)
17581817

17591818
async def _build_sql(self, sql: str) -> _RawRequest:
17601819
"""Build a SQL query GET request without sending it.

src/PowerPlatform/Dataverse/aio/operations/async_records.py

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
from __future__ import annotations
77

8-
from typing import Any, AsyncIterator, Dict, List, Optional, Union, overload, TYPE_CHECKING
8+
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union, overload, TYPE_CHECKING
99

10-
from ...models.record import Record
10+
from ...core.errors import HttpError
11+
from ...models.record import QueryResult, Record
1112
from ...models.upsert import UpsertItem
1213

1314
if TYPE_CHECKING:
@@ -463,6 +464,200 @@ async def _paged() -> AsyncIterator[List[Record]]:
463464

464465
return _paged()
465466

467+
# --------------------------------------------------------------- retrieve
468+
469+
async def retrieve(
470+
self,
471+
table: str,
472+
record_id: str,
473+
*,
474+
select: Optional[List[str]] = None,
475+
expand: Optional[List[str]] = None,
476+
include_annotations: Optional[str] = None,
477+
) -> Optional[Record]:
478+
"""Fetch a single record by its GUID, returning ``None`` if not found.
479+
480+
GA replacement for ``records.get(table, record_id)``. Returns ``None``
481+
instead of raising when the record does not exist (HTTP 404).
482+
483+
:param table: Schema name of the table (e.g. ``"account"``).
484+
:type table: :class:`str`
485+
:param record_id: GUID of the record to retrieve.
486+
:type record_id: :class:`str`
487+
:param select: Optional list of column logical names to include.
488+
:type select: list[str] or None
489+
:param expand: Optional list of navigation properties to expand (e.g.
490+
``["primarycontactid"]``). Navigation property names are
491+
case-sensitive and must match the entity's ``$metadata``.
492+
:type expand: list[str] or None
493+
:param include_annotations: OData annotation pattern for the
494+
``Prefer: odata.include-annotations`` header (e.g. ``"*"`` or
495+
``"OData.Community.Display.V1.FormattedValue"``), or ``None``.
496+
:type include_annotations: :class:`str` or None
497+
:return: Typed record, or ``None`` if not found.
498+
:rtype: :class:`~PowerPlatform.Dataverse.models.record.Record` or None
499+
500+
Example::
501+
502+
record = await client.records.retrieve(
503+
"account", account_id,
504+
select=["name", "statuscode"],
505+
expand=["primarycontactid"],
506+
include_annotations="OData.Community.Display.V1.FormattedValue",
507+
)
508+
if record is not None:
509+
contact = record.get("primarycontactid") or {}
510+
print(contact.get("fullname"))
511+
"""
512+
async with self._client._scoped_odata() as od:
513+
try:
514+
raw = await od._get(table, record_id, select=select, expand=expand, include_annotations=include_annotations)
515+
except HttpError as exc:
516+
if exc.status_code == 404:
517+
return None
518+
raise
519+
return Record.from_api_response(table, raw, record_id=record_id)
520+
521+
# -------------------------------------------------------------------- list
522+
523+
async def list(
524+
self,
525+
table: str,
526+
*,
527+
filter: Optional[Union[str, Any]] = None,
528+
select: Optional[List[str]] = None,
529+
orderby: Optional[List[str]] = None,
530+
top: Optional[int] = None,
531+
expand: Optional[List[str]] = None,
532+
page_size: Optional[int] = None,
533+
count: bool = False,
534+
include_annotations: Optional[str] = None,
535+
) -> QueryResult:
536+
"""Fetch multiple records and return them as a :class:`QueryResult`.
537+
538+
GA replacement for ``records.get(table, filter=...)``. All pages are
539+
collected eagerly and returned as a single :class:`QueryResult`.
540+
541+
:param table: Schema name of the table (e.g. ``"account"``).
542+
:type table: :class:`str`
543+
:param filter: Optional OData filter string or :class:`FilterExpression`.
544+
:type filter: str or FilterExpression or None
545+
:param select: Optional list of column logical names to include.
546+
:type select: list[str] or None
547+
:param orderby: Optional list of sort expressions (e.g. ``["name asc", "createdon desc"]``).
548+
:type orderby: list[str] or None
549+
:param top: Maximum total number of records to return.
550+
:type top: int or None
551+
:param expand: Optional list of navigation properties to expand.
552+
:type expand: list[str] or None
553+
:param page_size: Per-page size hint via ``Prefer: odata.maxpagesize``.
554+
:type page_size: int or None
555+
:param count: If ``True``, adds ``$count=true`` to include a total record count.
556+
:type count: bool
557+
:param include_annotations: OData annotation pattern for the
558+
``Prefer: odata.include-annotations`` header, or ``None``.
559+
:type include_annotations: :class:`str` or None
560+
:return: All matching records collected into a :class:`QueryResult`.
561+
:rtype: :class:`~PowerPlatform.Dataverse.models.record.QueryResult`
562+
563+
Example::
564+
565+
result = await client.records.list(
566+
"account",
567+
filter="statecode eq 0",
568+
select=["name", "statuscode"],
569+
orderby=["name asc"],
570+
top=100,
571+
include_annotations="OData.Community.Display.V1.FormattedValue",
572+
)
573+
for record in result:
574+
print(record["name"])
575+
"""
576+
filter_str: Optional[str] = str(filter) if filter is not None else None
577+
all_records: List[Record] = []
578+
async with self._client._scoped_odata() as od:
579+
async for page in od._get_multiple(
580+
table,
581+
select=select,
582+
filter=filter_str,
583+
orderby=orderby,
584+
top=top,
585+
expand=expand,
586+
page_size=page_size,
587+
count=count,
588+
include_annotations=include_annotations,
589+
):
590+
all_records.extend(Record.from_api_response(table, row) for row in page)
591+
return QueryResult(all_records)
592+
593+
# --------------------------------------------------------------- list_pages
594+
595+
async def list_pages(
596+
self,
597+
table: str,
598+
*,
599+
filter: Optional[Union[str, Any]] = None,
600+
select: Optional[List[str]] = None,
601+
orderby: Optional[List[str]] = None,
602+
top: Optional[int] = None,
603+
expand: Optional[List[str]] = None,
604+
page_size: Optional[int] = None,
605+
count: bool = False,
606+
include_annotations: Optional[str] = None,
607+
) -> AsyncIterator[QueryResult]:
608+
"""Lazily yield one :class:`QueryResult` per HTTP page.
609+
610+
Streaming counterpart to :meth:`list`. Each iteration triggers one
611+
network request via ``@odata.nextLink``. One-shot — do not iterate
612+
more than once.
613+
614+
:param table: Schema name of the table (e.g. ``"account"``).
615+
:type table: :class:`str`
616+
:param filter: Optional OData filter string or :class:`FilterExpression`.
617+
:type filter: str or FilterExpression or None
618+
:param select: Optional list of column logical names to include.
619+
:type select: list[str] or None
620+
:param orderby: Optional list of sort expressions.
621+
:type orderby: list[str] or None
622+
:param top: Maximum total number of records to return.
623+
:type top: int or None
624+
:param expand: Optional list of navigation properties to expand.
625+
:type expand: list[str] or None
626+
:param page_size: Per-page size hint via ``Prefer: odata.maxpagesize``.
627+
:type page_size: int or None
628+
:param count: If ``True``, adds ``$count=true`` to include a total record count.
629+
:type count: bool
630+
:param include_annotations: OData annotation pattern for the
631+
``Prefer: odata.include-annotations`` header, or ``None``.
632+
:type include_annotations: :class:`str` or None
633+
:return: Async iterator of per-page :class:`QueryResult` objects.
634+
:rtype: AsyncIterator[:class:`~PowerPlatform.Dataverse.models.record.QueryResult`]
635+
636+
Example::
637+
638+
async for page in client.records.list_pages(
639+
"account",
640+
filter="statecode eq 0",
641+
orderby=["name asc"],
642+
page_size=200,
643+
):
644+
process(page.to_dataframe())
645+
"""
646+
filter_str: Optional[str] = str(filter) if filter is not None else None
647+
async with self._client._scoped_odata() as od:
648+
async for page in od._get_multiple(
649+
table,
650+
select=select,
651+
filter=filter_str,
652+
orderby=orderby,
653+
top=top,
654+
expand=expand,
655+
page_size=page_size,
656+
count=count,
657+
include_annotations=include_annotations,
658+
):
659+
yield QueryResult([Record.from_api_response(table, row) for row in page])
660+
466661
# ------------------------------------------------------------------ upsert
467662

468663
async def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) -> None:

tests/unit/aio/data/test_async_batch_internal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ async def test_single_get_request(self):
268268
op = _RecordGet(table="account", record_id="guid-1", select=["name"])
269269
result = await client._resolve_record_get(op)
270270
assert len(result) == 1
271-
od._build_get.assert_called_once_with("account", "guid-1", select=["name"])
271+
od._build_get.assert_called_once_with("account", "guid-1", select=["name"], expand=None, include_annotations=None)
272272

273273

274274
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)