diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 84bd5d4..fd36da1 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -10,6 +10,7 @@ from .core._auth import _AuthManager from .core.config import DataverseConfig +from .core.results import FluentResult, RequestMetadata from .data._odata import _ODataClient @@ -108,7 +109,9 @@ def _scoped_odata(self) -> Iterator[_ODataClient]: yield od # ---------------- Unified CRUD: create/update/delete ---------------- - def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]: + def create( + self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]] + ) -> FluentResult[List[str]]: """ Create one or more records by table name. @@ -118,8 +121,9 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic Each dictionary should contain column schema names as keys. :type records: :class:`dict` or :class:`list` of :class:`dict` - :return: List of created record GUIDs. Returns a single-element list for a single input. - :rtype: :class:`list` of :class:`str` + :return: FluentResult wrapping list of created record GUIDs. Acts like a list + by default. Call ``.with_detail_response()`` for telemetry. + :rtype: :class:`~PowerPlatform.Dataverse.core.results.FluentResult` of :class:`list` of :class:`str` :raises TypeError: If ``records`` is not a dict or list[dict], or if the internal client returns an unexpected type. @@ -139,25 +143,31 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic ] ids = client.create("account", records) print(f"Created {len(ids)} accounts") + + Access telemetry with ``.with_detail_response()``:: + + response = client.create("account", {"name": "Test"}).with_detail_response() + print(f"Timing: {response.telemetry['timing_ms']}ms") """ with self._scoped_odata() as od: entity_set = od._entity_set_from_schema_name(table_schema_name) if isinstance(records, dict): - rid = od._create(entity_set, table_schema_name, records) - # _create returns str on single input + rid, metadata = od._create_with_metadata(entity_set, table_schema_name, records) if not isinstance(rid, str): raise TypeError("_create (single) did not return GUID string") - return [rid] + return FluentResult([rid], metadata, batch_info={"total": 1, "success": 1, "failures": 0}) if isinstance(records, list): - ids = od._create_multiple(entity_set, table_schema_name, records) + ids, metadata, batch_info = od._create_multiple_with_metadata( + entity_set, table_schema_name, records + ) if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids): raise TypeError("_create (multi) did not return list[str]") - return ids + return FluentResult(ids, metadata, batch_info=batch_info) raise TypeError("records must be dict or list[dict]") def update( self, table_schema_name: str, ids: Union[str, List[str]], changes: Union[Dict[str, Any], List[Dict[str, Any]]] - ) -> None: + ) -> FluentResult[None]: """ Update one or more records. @@ -177,6 +187,9 @@ def update( have equal length for one-to-one mapping. :type changes: :class:`dict` or :class:`list` of :class:`dict` + :return: FluentResult wrapping None. Call ``.with_detail_response()`` for telemetry. + :rtype: :class:`~PowerPlatform.Dataverse.core.results.FluentResult` of ``None`` + :raises TypeError: If ``ids`` is not str or list[str], or if ``changes`` type doesn't match usage pattern. .. note:: @@ -199,24 +212,34 @@ def update( {"name": "Updated Name 2"} ] client.update("account", ids, changes) + + Access telemetry with ``.with_detail_response()``:: + + response = client.update("account", id, {"name": "Test"}).with_detail_response() + print(f"Timing: {response.telemetry['timing_ms']}ms") """ with self._scoped_odata() as od: if isinstance(ids, str): if not isinstance(changes, dict): raise TypeError("For single id, changes must be a dict") - od._update(table_schema_name, ids, changes) # discard representation - return None + _, metadata = od._update_with_metadata(table_schema_name, ids, changes) + return FluentResult(None, metadata) if not isinstance(ids, list): raise TypeError("ids must be str or list[str]") + # For bulk updates, we still use the original method as _update_by_ids doesn't have a _with_metadata variant yet + # TODO: Add _update_by_ids_with_metadata for bulk update telemetry od._update_by_ids(table_schema_name, ids, changes) - return None + # Create placeholder metadata for bulk updates + placeholder_metadata = RequestMetadata() + num_updates = len(ids) + return FluentResult(None, placeholder_metadata, batch_info={"total": num_updates, "success": num_updates, "failures": 0}) def delete( self, table_schema_name: str, ids: Union[str, List[str]], use_bulk_delete: bool = True, - ) -> Optional[str]: + ) -> FluentResult[Optional[str]]: """ Delete one or more records by GUID. @@ -228,12 +251,13 @@ def delete( return its async job identifier. When ``False`` each record is deleted sequentially. :type use_bulk_delete: :class:`bool` + :return: FluentResult wrapping BulkDelete job ID (for bulk) or None (for single). + Call ``.with_detail_response()`` for telemetry. + :rtype: :class:`~PowerPlatform.Dataverse.core.results.FluentResult` of :class:`str` or ``None`` + :raises TypeError: If ``ids`` is not str or list[str]. :raises HttpError: If the underlying Web API delete request fails. - :return: BulkDelete job ID when deleting multiple records via BulkDelete; otherwise ``None``. - :rtype: :class:`str` or None - Example: Delete a single record:: @@ -242,22 +266,31 @@ def delete( Delete multiple records:: job_id = client.delete("account", [id1, id2, id3]) + + Access telemetry with ``.with_detail_response()``:: + + response = client.delete("account", account_id).with_detail_response() + print(f"Timing: {response.telemetry['timing_ms']}ms") """ with self._scoped_odata() as od: if isinstance(ids, str): - od._delete(table_schema_name, ids) - return None + _, metadata = od._delete_with_metadata(table_schema_name, ids) + return FluentResult(None, metadata) if not isinstance(ids, list): raise TypeError("ids must be str or list[str]") if not ids: - return None + return FluentResult(None, RequestMetadata()) if not all(isinstance(rid, str) for rid in ids): raise TypeError("ids must contain string GUIDs") if use_bulk_delete: - return od._delete_multiple(table_schema_name, ids) + # TODO: Add _delete_multiple_with_metadata for bulk delete telemetry + job_id = od._delete_multiple(table_schema_name, ids) + return FluentResult(job_id, RequestMetadata(), batch_info={"total": len(ids)}) + # Sequential deletes + last_metadata = RequestMetadata() for rid in ids: - od._delete(table_schema_name, rid) - return None + _, last_metadata = od._delete_with_metadata(table_schema_name, rid) + return FluentResult(None, last_metadata, batch_info={"total": len(ids), "success": len(ids), "failures": 0}) def get( self, @@ -269,11 +302,11 @@ def get( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Union[Dict[str, Any], Iterable[List[Dict[str, Any]]]]: + ) -> Union[FluentResult[Dict[str, Any]], Iterable[List[Dict[str, Any]]]]: """ Fetch a single record by ID or query multiple records. - When ``record_id`` is provided, returns a single record dictionary. + When ``record_id`` is provided, returns a FluentResult wrapping the record dictionary. When ``record_id`` is None, returns a generator yielding batches of records. :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). @@ -293,9 +326,11 @@ def get( :param page_size: Optional number of records per page for pagination. :type page_size: :class:`int` or None - :return: Single record dict if ``record_id`` is provided, otherwise a generator + :return: FluentResult wrapping single record dict if ``record_id`` is provided + (call ``.with_detail_response()`` for telemetry), otherwise a generator yielding lists of record dictionaries (one list per page). - :rtype: :class:`dict` or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` + :rtype: :class:`~PowerPlatform.Dataverse.core.results.FluentResult` of :class:`dict` + or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` :raises TypeError: If ``record_id`` is provided but not a string. @@ -305,6 +340,11 @@ def get( record = client.get("account", record_id=account_id, select=["name", "telephone1"]) print(record["name"]) + Access telemetry for single record fetch:: + + response = client.get("account", record_id=account_id).with_detail_response() + print(f"Timing: {response.telemetry['timing_ms']}ms") + Query multiple records with filtering (note: exact logical names in filter):: for batch in client.get( @@ -340,11 +380,12 @@ def get( if not isinstance(record_id, str): raise TypeError("record_id must be str") with self._scoped_odata() as od: - return od._get( + record, metadata = od._get_with_metadata( table_schema_name, record_id, select=select, ) + return FluentResult(record, metadata) def _paged() -> Iterable[List[Dict[str, Any]]]: with self._scoped_odata() as od: diff --git a/src/PowerPlatform/Dataverse/core/__init__.py b/src/PowerPlatform/Dataverse/core/__init__.py index 79454f5..269bea7 100644 --- a/src/PowerPlatform/Dataverse/core/__init__.py +++ b/src/PowerPlatform/Dataverse/core/__init__.py @@ -8,4 +8,30 @@ configuration, HTTP client, and error handling. """ -__all__ = [] +from .results import ( + # New fluent API types + RequestMetadata, + DataverseResponse, + FluentResult, + # Legacy types (backward compatible) + OperationResult, + CreateResult, + UpdateResult, + DeleteResult, + GetResult, + PagedResult, +) + +__all__ = [ + # New fluent API types + "RequestMetadata", + "DataverseResponse", + "FluentResult", + # Legacy types (backward compatible) + "OperationResult", + "CreateResult", + "UpdateResult", + "DeleteResult", + "GetResult", + "PagedResult", +] diff --git a/src/PowerPlatform/Dataverse/core/_http.py b/src/PowerPlatform/Dataverse/core/_http.py index 43f6889..17448c0 100644 --- a/src/PowerPlatform/Dataverse/core/_http.py +++ b/src/PowerPlatform/Dataverse/core/_http.py @@ -12,11 +12,26 @@ from __future__ import annotations import time -from typing import Any, Optional +from dataclasses import dataclass +from typing import Any, Optional, Tuple import requests +@dataclass +class _HttpTiming: + """Timing information for an HTTP request. + + :param elapsed_ms: Total request duration in milliseconds. + :type elapsed_ms: :class:`float` + :param attempts: Number of attempts made (1 = no retries). + :type attempts: :class:`int` + """ + + elapsed_ms: float + attempts: int = 1 + + class _HttpClient: """ HTTP client with configurable retry logic and timeout handling. @@ -77,3 +92,50 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: delay = self.base_delay * (2**attempt) time.sleep(delay) continue + + def _request_with_timing( + self, method: str, url: str, **kwargs: Any + ) -> Tuple[requests.Response, _HttpTiming]: + """ + Execute an HTTP request and return response with timing information. + + Same behavior as :meth:`_request` but additionally returns timing data + for telemetry purposes. + + :param method: HTTP method (GET, POST, PUT, DELETE, etc.). + :type method: :class:`str` + :param url: Target URL for the request. + :type url: :class:`str` + :param kwargs: Additional arguments passed to ``requests.request()``. + :return: Tuple of (HTTP response, timing information). + :rtype: :class:`tuple` of (:class:`requests.Response`, :class:`_HttpTiming`) + :raises requests.exceptions.RequestException: If all retry attempts fail. + """ + # If no timeout is provided, use the user-specified default timeout if set; + # otherwise, apply per-method defaults (120s for POST/DELETE, 10s for others). + if "timeout" not in kwargs: + if self.default_timeout is not None: + kwargs["timeout"] = self.default_timeout + else: + m = (method or "").lower() + kwargs["timeout"] = 120 if m in ("post", "delete") else 10 + + start_time = time.time() + attempts = 0 + + # Small backoff retry on network errors only + for attempt in range(self.max_attempts): + attempts = attempt + 1 + try: + response = requests.request(method, url, **kwargs) + elapsed_ms = (time.time() - start_time) * 1000 + return response, _HttpTiming(elapsed_ms=elapsed_ms, attempts=attempts) + except requests.exceptions.RequestException: + if attempt == self.max_attempts - 1: + raise + delay = self.base_delay * (2**attempt) + time.sleep(delay) + continue + + # This should not be reached, but include for type safety + raise RuntimeError("Unexpected state in _request_with_timing") diff --git a/src/PowerPlatform/Dataverse/core/results.py b/src/PowerPlatform/Dataverse/core/results.py new file mode 100644 index 0000000..6fd7be5 --- /dev/null +++ b/src/PowerPlatform/Dataverse/core/results.py @@ -0,0 +1,484 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Result types for Dataverse SDK operations. + +This module provides structured result types that wrap operation outcomes with +request metadata for debugging and tracing: + +**Core Types (New API):** + +- :class:`RequestMetadata`: HTTP request/response metadata for diagnostics +- :class:`DataverseResponse`: Standard response object with result and telemetry +- :class:`FluentResult`: Wrapper enabling fluent ``.with_detail_response()`` pattern + +**Legacy Types (Backward Compatible):** + +- :class:`OperationResult`: Base result with request IDs for any operation +- :class:`CreateResult`: Result from create operations containing record GUIDs +- :class:`UpdateResult`: Result from update operations +- :class:`DeleteResult`: Result from delete operations (may include bulk job ID) +- :class:`GetResult`: Result from single-record fetch operations +- :class:`PagedResult`: Result for paginated queries, yielded per page + +The new :class:`FluentResult` wrapper enables a fluent API pattern where operations +return values that act like their underlying results by default, but can optionally +return detailed telemetry via ``.with_detail_response()``. + +Example:: + + # Default behavior - acts like the result directly + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(ids[0]) # Works via __getitem__ + + # Detailed response with telemetry + response = client.create("account", [{"name": "A"}]).with_detail_response() + print(response.result) # ['guid-123'] + print(response.telemetry['timing_ms']) # 150 +""" + +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, TypeVar, Generic, Iterator, Union + + +@dataclass(frozen=True) +class OperationResult: + """ + Base result containing request metadata for any Dataverse operation. + + All result types inherit from this to provide consistent access to + request tracking IDs for debugging and distributed tracing. + + :param client_request_id: Client-generated request ID sent in ``x-ms-client-request-id`` header. + :type client_request_id: :class:`str` | None + :param correlation_id: Client-generated correlation ID sent in ``x-ms-correlation-id`` header, + shared across all HTTP requests within a single SDK call scope. + :type correlation_id: :class:`str` | None + :param service_request_id: Server-returned ``x-ms-service-request-id`` (if available). + Typically only populated on error responses. + :type service_request_id: :class:`str` | None + """ + + client_request_id: Optional[str] = None + correlation_id: Optional[str] = None + service_request_id: Optional[str] = None + + +@dataclass(frozen=True) +class CreateResult(OperationResult): + """ + Result from a create operation containing the created record GUID(s). + + :param ids: List of created record GUIDs. Single-element list for single creates. + :type ids: :class:`list` of :class:`str` + + Example: + Create a single record:: + + result = client.create("account", {"name": "Contoso"}) + print(f"Created: {result.ids[0]}") + print(f"Request ID: {result.client_request_id}") + + Create multiple records:: + + result = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(f"Created {len(result.ids)} records") + """ + + ids: List[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class UpdateResult(OperationResult): + """ + Result from an update operation. + + Update operations don't return data, but the result provides request + metadata for debugging. + + Example: + Update a record:: + + result = client.update("account", account_id, {"name": "New Name"}) + print(f"Update completed, request ID: {result.client_request_id}") + """ + + pass + + +@dataclass(frozen=True) +class DeleteResult(OperationResult): + """ + Result from a delete operation. + + :param bulk_job_id: Async job ID when using BulkDelete for multiple records. + ``None`` for single deletes or sequential multi-delete. + :type bulk_job_id: :class:`str` | None + + Example: + Delete multiple records via BulkDelete:: + + result = client.delete("account", [id1, id2, id3]) + if result.bulk_job_id: + print(f"Bulk delete job: {result.bulk_job_id}") + """ + + bulk_job_id: Optional[str] = None + + +@dataclass(frozen=True) +class GetResult(OperationResult): + """ + Result from fetching a single record by ID. + + :param record: The retrieved record as a dictionary. + :type record: :class:`dict` + + Example: + Fetch a single record:: + + result = client.get("account", record_id=account_id) + print(f"Name: {result.record['name']}") + print(f"Request ID: {result.client_request_id}") + """ + + record: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PagedResult(OperationResult): + """ + Result for a single page of a paginated query. + + Yielded by ``get()`` when querying multiple records. Each page carries + its own request metadata from the HTTP request that fetched it. + + :param records: List of record dictionaries in this page. + :type records: :class:`list` of :class:`dict` + :param page_number: 1-based page number (first page is 1). + :type page_number: :class:`int` + :param has_more: Whether more pages are available after this one. + :type has_more: :class:`bool` + + Example: + Iterate through pages:: + + for page in client.get("account", filter="statecode eq 0"): + print(f"Page {page.page_number}: {len(page.records)} records") + print(f"Request ID: {page.client_request_id}") + for record in page.records: + print(record["name"]) + """ + + records: List[Dict[str, Any]] = field(default_factory=list) + page_number: int = 0 + has_more: bool = False + + +# ============================================================================= +# New Fluent API Types (Phase 1 Implementation) +# ============================================================================= + +# Type variable for generic result types +T = TypeVar('T') + + +@dataclass(frozen=True) +class RequestMetadata: + """ + HTTP request/response metadata for diagnostics and tracing. + + This dataclass captures metadata from HTTP requests for debugging, + monitoring, and distributed tracing scenarios. + + :param client_request_id: Client-generated request ID sent in + ``x-ms-client-request-id`` header. + :type client_request_id: :class:`str` | None + :param correlation_id: Client-generated correlation ID sent in + ``x-ms-correlation-id`` header, shared across all HTTP requests + within a single SDK call scope. + :type correlation_id: :class:`str` | None + :param service_request_id: Server-returned ``x-ms-service-request-id`` + header value (if available). + :type service_request_id: :class:`str` | None + :param http_status_code: HTTP response status code. + :type http_status_code: :class:`int` | None + :param timing_ms: Operation duration in milliseconds. + :type timing_ms: :class:`float` | None + + Example:: + + metadata = RequestMetadata( + client_request_id="abc-123", + correlation_id="corr-456", + http_status_code=201, + timing_ms=150.5 + ) + """ + + client_request_id: Optional[str] = None + correlation_id: Optional[str] = None + service_request_id: Optional[str] = None + http_status_code: Optional[int] = None + timing_ms: Optional[float] = None + + +@dataclass +class DataverseResponse(Generic[T]): + """ + Standard response object for all Dataverse operations. + + This class provides a consistent structure for operation responses, + combining the operation result with telemetry data for monitoring + and debugging. + + :param result: The operation result (IDs, records, etc.). The type + depends on the operation: + + - Create single: ``str`` (record ID) + - Create bulk: ``list[str]`` (record IDs) + - Update: ``None`` + - Delete single: ``None`` + - Delete bulk: ``str | None`` (bulk job ID) + - Get single: ``dict`` (record) + - Query: ``list[dict]`` (records) + + :type result: T + :param telemetry: Dictionary containing telemetry data: + + - ``client_request_id``: Client request ID + - ``correlation_id``: Correlation ID for the operation scope + - ``service_request_id``: Server-side request ID + - ``http_status_code``: HTTP response status code + - ``timing_ms``: Operation duration in milliseconds + - ``batch_info``: Batch details for bulk operations (optional) + + :type telemetry: :class:`dict` + + Example:: + + response = client.create("account", [{"name": "A"}]).with_detail_response() + print(response.result) # ['guid-123'] + print(response.telemetry['timing_ms']) # 150.5 + print(response.telemetry['http_status_code']) # 200 + """ + + result: T + telemetry: Dict[str, Any] = field(default_factory=dict) + + +class FluentResult(Generic[T]): + """ + Wrapper enabling fluent ``.with_detail_response()`` pattern. + + This class wraps operation results to provide a fluent API where: + + - **Default behavior**: Acts like the result directly (supports iteration, + indexing, string conversion, equality, etc.) + - **Detailed behavior**: Call ``.with_detail_response()`` to get a + :class:`DataverseResponse` with telemetry data + + This pattern allows existing code to continue working unchanged while + enabling new code to access detailed telemetry when needed. + + :param result: The operation result value. + :type result: T + :param metadata: HTTP request/response metadata. + :type metadata: :class:`RequestMetadata` + :param batch_info: Optional batch information for bulk operations. + :type batch_info: :class:`dict` | None + + Example:: + + # Default behavior - acts like the result directly + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(ids[0]) # Works via __getitem__ + for id in ids: # Works via __iter__ + print(id) + + # Detailed response with telemetry + response = client.create("account", [{"name": "A"}]).with_detail_response() + print(response.result) # ['guid-123'] + print(response.telemetry['timing_ms']) # 150 + print(response.telemetry['batch_info']) # {'total': 1, 'success': 1} + """ + + __slots__ = ('_result', '_metadata', '_batch_info') + + def __init__( + self, + result: T, + metadata: RequestMetadata, + batch_info: Optional[Dict[str, Any]] = None + ) -> None: + self._result = result + self._metadata = metadata + self._batch_info = batch_info + + @property + def value(self) -> T: + """ + Direct access to the result value. + + This property provides explicit access to the underlying result + when the magic methods are not sufficient. + + :return: The operation result. + :rtype: T + """ + return self._result + + def with_detail_response(self) -> DataverseResponse[T]: + """ + Return detailed response with telemetry. + + Converts this fluent result into a :class:`DataverseResponse` that + includes both the operation result and telemetry data. + + :return: A DataverseResponse containing result and telemetry. + :rtype: :class:`DataverseResponse` + + Example:: + + response = client.create("account", {"name": "A"}).with_detail_response() + print(response.result) # 'guid-123' + print(response.telemetry['timing_ms']) # 150.5 + """ + telemetry: Dict[str, Any] = { + "client_request_id": self._metadata.client_request_id, + "correlation_id": self._metadata.correlation_id, + "service_request_id": self._metadata.service_request_id, + "http_status_code": self._metadata.http_status_code, + "timing_ms": self._metadata.timing_ms, + } + if self._batch_info is not None: + telemetry["batch_info"] = self._batch_info + return DataverseResponse(result=self._result, telemetry=telemetry) + + # ------------------------------------------------------------------------- + # Magic methods for transparent default behavior + # ------------------------------------------------------------------------- + + def __iter__(self) -> Iterator: + """ + Support iteration for default behavior. + + Allows iterating over the result when it's a sequence. + For non-sequence results, yields the single result. + + Example:: + + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + for id in ids: + print(id) + """ + if isinstance(self._result, (list, tuple)): + return iter(self._result) + return iter([self._result]) + + def __getitem__(self, key: Any) -> Any: + """ + Support indexing for default behavior. + + Allows accessing elements by index or key depending on the result type. + + Example:: + + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(ids[0]) # First ID + """ + return self._result[key] # type: ignore + + def __len__(self) -> int: + """ + Return the length of the result. + + For sequences, returns the number of elements. + For non-sequences, returns 1. + + Example:: + + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(len(ids)) # 2 + """ + if isinstance(self._result, (list, tuple, dict)): + return len(self._result) + return 1 + + def __str__(self) -> str: + """Return string representation of the result.""" + return str(self._result) + + def __repr__(self) -> str: + """Return detailed representation for debugging.""" + return f"FluentResult({self._result!r})" + + def __eq__(self, other: object) -> bool: + """ + Compare for equality. + + Compares against both FluentResult instances and raw values. + + Example:: + + result1 = client.create("account", {"name": "A"}) + result2 = client.create("account", {"name": "A"}) + print(result1 == result2) # True if same IDs + """ + if isinstance(other, FluentResult): + return self._result == other._result + return self._result == other + + def __bool__(self) -> bool: + """ + Return truthiness of the result. + + Example:: + + result = client.delete("account", "guid-123") + if result: # True if result is truthy + print("Deleted") + """ + return bool(self._result) + + def __contains__(self, item: Any) -> bool: + """ + Support ``in`` operator for sequences. + + Example:: + + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + if "guid-123" in ids: + print("Found!") + """ + if isinstance(self._result, (list, tuple, dict, str)): + return item in self._result + return item == self._result + + def __hash__(self) -> int: + """ + Return hash for hashable results. + + Note: Only works if the underlying result is hashable. + """ + if isinstance(self._result, (list, dict)): + # Lists and dicts are not hashable + raise TypeError(f"unhashable type: 'FluentResult' with {type(self._result).__name__}") + return hash(self._result) + + +__all__ = [ + # Legacy types (backward compatible) + "OperationResult", + "CreateResult", + "UpdateResult", + "DeleteResult", + "GetResult", + "PagedResult", + # New fluent API types + "RequestMetadata", + "DataverseResponse", + "FluentResult", +] diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 7c5fc6c..5090a7c 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -18,7 +18,7 @@ from contextlib import contextmanager from contextvars import ContextVar -from ..core._http import _HttpClient +from ..core._http import _HttpClient, _HttpTiming from ._upload import _ODataFileUpload from ..core.errors import * from ..core._error_codes import ( @@ -33,6 +33,7 @@ METADATA_COLUMN_NOT_FOUND, VALIDATION_UNSUPPORTED_CACHE_KIND, ) +from ..core.results import RequestMetadata from ..__version__ import __version__ as _SDK_VERSION @@ -251,6 +252,279 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL is_transient=is_transient, ) + def _request_with_metadata( + self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAULT_EXPECTED_STATUSES, **kwargs + ) -> tuple[Any, RequestMetadata]: + """Execute HTTP request and return response with RequestMetadata. + + This method is used internally to capture telemetry for the fluent + ``.with_detail_response()`` API pattern. + + :param method: HTTP method (GET, POST, PUT, DELETE, etc.). + :type method: ``str`` + :param url: Target URL for the request. + :type url: ``str`` + :param expected: Tuple of acceptable HTTP status codes. + :type expected: ``tuple[int, ...]`` + :param kwargs: Additional arguments passed to the underlying HTTP request. + :return: Tuple of (response, RequestMetadata). + :rtype: ``tuple[Any, RequestMetadata]`` + :raises HttpError: If the response status is not in expected statuses. + """ + request_context = _RequestContext.build( + method, + url, + expected=expected, + merge_headers=self._merge_headers, + **kwargs, + ) + + # Use timing-aware request + r, timing = self._http._request_with_timing( + request_context.method, request_context.url, **request_context.kwargs + ) + + # Extract service request ID from response headers + response_headers = getattr(r, "headers", {}) or {} + service_request_id = ( + response_headers.get("x-ms-service-request-id") + or response_headers.get("req_id") + or response_headers.get("x-ms-request-id") + ) + + # Build metadata + metadata = RequestMetadata( + client_request_id=request_context.headers.get("x-ms-client-request-id"), + correlation_id=request_context.headers.get("x-ms-correlation-id"), + service_request_id=service_request_id, + http_status_code=r.status_code, + timing_ms=timing.elapsed_ms, + ) + + if r.status_code in request_context.expected: + return r, metadata + + # Error handling - same logic as _request but we have metadata available + body_excerpt = (getattr(r, "text", "") or "")[:200] + svc_code = None + msg = f"HTTP {r.status_code}" + try: + data = r.json() if getattr(r, "text", None) else {} + if isinstance(data, dict): + inner = data.get("error") + if isinstance(inner, dict): + svc_code = inner.get("code") + imsg = inner.get("message") + if isinstance(imsg, str) and imsg.strip(): + msg = imsg.strip() + else: + imsg2 = data.get("message") + if isinstance(imsg2, str) and imsg2.strip(): + msg = imsg2.strip() + except Exception: + pass + sc = r.status_code + subcode = _http_subcode(sc) + traceparent = response_headers.get("traceparent") + ra = response_headers.get("Retry-After") + retry_after = None + if ra: + try: + retry_after = int(ra) + except Exception: + retry_after = None + is_transient = _is_transient_status(sc) + raise HttpError( + msg, + status_code=sc, + subcode=subcode, + service_error_code=svc_code, + correlation_id=metadata.correlation_id, + client_request_id=metadata.client_request_id, + service_request_id=metadata.service_request_id, + traceparent=traceparent, + body_excerpt=body_excerpt, + retry_after=retry_after, + is_transient=is_transient, + ) + + # --- CRUD Internal functions with metadata --- + def _create_with_metadata( + self, entity_set: str, table_schema_name: str, record: Dict[str, Any] + ) -> tuple[str, RequestMetadata]: + """Create a single record and return its GUID with metadata. + + Same as :meth:`_create` but returns a tuple of (record_id, RequestMetadata) + for the fluent API pattern. + + :param entity_set: Resolved entity set (plural) name. + :type entity_set: ``str`` + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param record: Attribute payload mapped by logical column names. + :type record: ``dict[str, Any]`` + :return: Tuple of (created record GUID, request metadata). + :rtype: ``tuple[str, RequestMetadata]`` + """ + record = self._lowercase_keys(record) + record = self._convert_labels_to_ints(table_schema_name, record) + url = f"{self.api}/{entity_set}" + r, metadata = self._request_with_metadata("post", url, json=record) + + ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID") + if ent_loc: + m = _GUID_RE.search(ent_loc) + if m: + return m.group(0), metadata + loc = r.headers.get("Location") + if loc: + m = _GUID_RE.search(loc) + if m: + return m.group(0), metadata + header_keys = ", ".join(sorted(r.headers.keys())) + raise RuntimeError( + f"Create response missing GUID in OData-EntityId/Location headers (status={getattr(r,'status_code', '?')}). Headers: {header_keys}" + ) + + def _create_multiple_with_metadata( + self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]] + ) -> tuple[List[str], RequestMetadata, Dict[str, Any]]: + """Create multiple records and return GUIDs with metadata. + + Same as :meth:`_create_multiple` but returns a tuple of + (record_ids, RequestMetadata, batch_info) for the fluent API pattern. + + :param entity_set: Resolved entity set (plural) name. + :type entity_set: ``str`` + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param records: Payload dictionaries mapped by column schema names. + :type records: ``list[dict[str, Any]]`` + :return: Tuple of (list of created GUIDs, request metadata, batch info). + :rtype: ``tuple[list[str], RequestMetadata, dict[str, Any]]`` + """ + if not all(isinstance(r, dict) for r in records): + raise TypeError("All items for multi-create must be dicts") + need_logical = any("@odata.type" not in r for r in records) + logical_name = table_schema_name.lower() + enriched: List[Dict[str, Any]] = [] + for r in records: + r = self._lowercase_keys(r) + r = self._convert_labels_to_ints(table_schema_name, r) + if "@odata.type" in r or not need_logical: + enriched.append(r) + else: + nr = r.copy() + nr["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}" + enriched.append(nr) + payload = {"Targets": enriched} + url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.CreateMultiple" + r, metadata = self._request_with_metadata("post", url, json=payload) + + try: + body = r.json() if r.text else {} + except ValueError: + body = {} + if not isinstance(body, dict): + body = {} + + ids: List[str] = [] + raw_ids = body.get("Ids") + if isinstance(raw_ids, list): + ids = [i for i in raw_ids if isinstance(i, str)] + else: + value = body.get("value") + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + for k, v in item.items(): + if isinstance(k, str) and k.lower().endswith("id") and isinstance(v, str) and len(v) >= 32: + ids.append(v) + break + + batch_info = { + "total": len(records), + "success": len(ids), + "failures": len(records) - len(ids), + } + return ids, metadata, batch_info + + def _update_with_metadata( + self, table_schema_name: str, key: str, data: Dict[str, Any] + ) -> tuple[None, RequestMetadata]: + """Update a single record and return metadata. + + Same as :meth:`_update` but returns a tuple of (None, RequestMetadata) + for the fluent API pattern. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param key: Record GUID. + :type key: ``str`` + :param data: Attribute changes. + :type data: ``dict[str, Any]`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestMetadata]`` + """ + data = self._lowercase_keys(data) + data = self._convert_labels_to_ints(table_schema_name, data) + entity_set = self._entity_set_from_schema_name(table_schema_name) + url = f"{self.api}/{entity_set}({self._format_key(key)})" + _, metadata = self._request_with_metadata("patch", url, json=data) + return None, metadata + + def _delete_with_metadata( + self, table_schema_name: str, key: str + ) -> tuple[None, RequestMetadata]: + """Delete a single record and return metadata. + + Same as :meth:`_delete` but returns a tuple of (None, RequestMetadata) + for the fluent API pattern. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param key: Record GUID. + :type key: ``str`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestMetadata]`` + """ + entity_set = self._entity_set_from_schema_name(table_schema_name) + url = f"{self.api}/{entity_set}({self._format_key(key)})" + _, metadata = self._request_with_metadata("delete", url) + return None, metadata + + def _get_with_metadata( + self, + table_schema_name: str, + key: str, + *, + select: Optional[List[str]] = None, + ) -> tuple[Dict[str, Any], RequestMetadata]: + """Get a single record by ID and return with metadata. + + Same as :meth:`_get` but returns a tuple of (record, RequestMetadata) + for the fluent API pattern. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param key: Record GUID. + :type key: ``str`` + :param select: Optional list of columns to select. + :type select: ``list[str]`` | ``None`` + :return: Tuple of (record dict, request metadata). + :rtype: ``tuple[dict[str, Any], RequestMetadata]`` + """ + entity_set = self._entity_set_from_schema_name(table_schema_name) + url = f"{self.api}/{entity_set}({self._format_key(key)})" + if select: + select = self._lowercase_list(select) + url += "?$select=" + ",".join(select) + r, metadata = self._request_with_metadata("get", url) + try: + return r.json(), metadata + except ValueError: + return {}, metadata + # --- CRUD Internal functions --- def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any]) -> str: """Create a single record and return its GUID. diff --git a/tests/unit/core/test_results.py b/tests/unit/core/test_results.py new file mode 100644 index 0000000..810a0c3 --- /dev/null +++ b/tests/unit/core/test_results.py @@ -0,0 +1,440 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Unit tests for result types in PowerPlatform.Dataverse.core.results. + +Tests cover: +- RequestMetadata dataclass behavior +- DataverseResponse dataclass behavior +- FluentResult wrapper with .with_detail_response() pattern +- Magic method behaviors for FluentResult (iteration, indexing, equality, etc.) +""" + +import pytest +from PowerPlatform.Dataverse.core.results import ( + RequestMetadata, + DataverseResponse, + FluentResult, + # Legacy types + OperationResult, + CreateResult, + UpdateResult, + DeleteResult, + GetResult, + PagedResult, +) + + +class TestRequestMetadata: + """Tests for RequestMetadata dataclass.""" + + def test_default_values(self): + """Test that RequestMetadata has correct default values.""" + metadata = RequestMetadata() + assert metadata.client_request_id is None + assert metadata.correlation_id is None + assert metadata.service_request_id is None + assert metadata.http_status_code is None + assert metadata.timing_ms is None + + def test_with_all_values(self): + """Test RequestMetadata with all values provided.""" + metadata = RequestMetadata( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="service-789", + http_status_code=201, + timing_ms=150.5 + ) + assert metadata.client_request_id == "client-123" + assert metadata.correlation_id == "corr-456" + assert metadata.service_request_id == "service-789" + assert metadata.http_status_code == 201 + assert metadata.timing_ms == 150.5 + + def test_is_frozen(self): + """Test that RequestMetadata is immutable (frozen).""" + metadata = RequestMetadata(client_request_id="test") + with pytest.raises(AttributeError): + metadata.client_request_id = "new-value" # type: ignore + + def test_equality(self): + """Test RequestMetadata equality comparison.""" + m1 = RequestMetadata(client_request_id="test", http_status_code=200) + m2 = RequestMetadata(client_request_id="test", http_status_code=200) + m3 = RequestMetadata(client_request_id="other", http_status_code=200) + assert m1 == m2 + assert m1 != m3 + + +class TestDataverseResponse: + """Tests for DataverseResponse dataclass.""" + + def test_with_string_result(self): + """Test DataverseResponse with a string result (single create).""" + response = DataverseResponse( + result="guid-123", + telemetry={"timing_ms": 100} + ) + assert response.result == "guid-123" + assert response.telemetry["timing_ms"] == 100 + + def test_with_list_result(self): + """Test DataverseResponse with a list result (bulk create).""" + response = DataverseResponse( + result=["guid-1", "guid-2", "guid-3"], + telemetry={"batch_info": {"total": 3, "success": 3}} + ) + assert response.result == ["guid-1", "guid-2", "guid-3"] + assert len(response.result) == 3 + assert response.telemetry["batch_info"]["total"] == 3 + + def test_with_none_result(self): + """Test DataverseResponse with None result (update/delete).""" + response = DataverseResponse( + result=None, + telemetry={"http_status_code": 204} + ) + assert response.result is None + assert response.telemetry["http_status_code"] == 204 + + def test_default_telemetry(self): + """Test DataverseResponse default telemetry is empty dict.""" + response = DataverseResponse(result="test") + assert response.telemetry == {} + + def test_telemetry_structure(self): + """Test typical telemetry structure.""" + telemetry = { + "client_request_id": "client-123", + "correlation_id": "corr-456", + "service_request_id": "service-789", + "http_status_code": 200, + "timing_ms": 150.5, + "batch_info": {"total": 2, "success": 2, "failures": 0} + } + response = DataverseResponse(result=["id1", "id2"], telemetry=telemetry) + assert response.telemetry["timing_ms"] == 150.5 + assert response.telemetry["batch_info"]["success"] == 2 + + +class TestFluentResult: + """Tests for FluentResult wrapper class.""" + + def test_value_property(self): + """Test the value property returns the result directly.""" + metadata = RequestMetadata() + result = FluentResult("guid-123", metadata) + assert result.value == "guid-123" + + def test_with_detail_response_single(self): + """Test with_detail_response() for single record result.""" + metadata = RequestMetadata( + client_request_id="client-123", + correlation_id="corr-456", + http_status_code=201, + timing_ms=100.5 + ) + result = FluentResult("guid-123", metadata) + response = result.with_detail_response() + + assert isinstance(response, DataverseResponse) + assert response.result == "guid-123" + assert response.telemetry["client_request_id"] == "client-123" + assert response.telemetry["correlation_id"] == "corr-456" + assert response.telemetry["http_status_code"] == 201 + assert response.telemetry["timing_ms"] == 100.5 + assert "batch_info" not in response.telemetry + + def test_with_detail_response_bulk(self): + """Test with_detail_response() for bulk result with batch_info.""" + metadata = RequestMetadata( + client_request_id="client-123", + http_status_code=200, + timing_ms=250.0 + ) + batch_info = {"total": 3, "success": 3, "failures": 0} + result = FluentResult(["id1", "id2", "id3"], metadata, batch_info=batch_info) + response = result.with_detail_response() + + assert response.result == ["id1", "id2", "id3"] + assert response.telemetry["batch_info"] == batch_info + assert response.telemetry["batch_info"]["total"] == 3 + + def test_iteration_list(self): + """Test iteration over a list result.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b", "c"], metadata) + items = list(result) + assert items == ["a", "b", "c"] + + def test_iteration_single(self): + """Test iteration over a single value result.""" + metadata = RequestMetadata() + result = FluentResult("single", metadata) + items = list(result) + assert items == ["single"] + + def test_getitem_list(self): + """Test indexing into a list result.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b", "c"], metadata) + assert result[0] == "a" + assert result[1] == "b" + assert result[-1] == "c" + + def test_getitem_dict(self): + """Test key access for dict result.""" + metadata = RequestMetadata() + result = FluentResult({"name": "Contoso", "id": "123"}, metadata) + assert result["name"] == "Contoso" + assert result["id"] == "123" + + def test_len_list(self): + """Test len() for list result.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b", "c"], metadata) + assert len(result) == 3 + + def test_len_dict(self): + """Test len() for dict result.""" + metadata = RequestMetadata() + result = FluentResult({"a": 1, "b": 2}, metadata) + assert len(result) == 2 + + def test_len_single(self): + """Test len() for single value result.""" + metadata = RequestMetadata() + result = FluentResult("single", metadata) + assert len(result) == 1 + + def test_str(self): + """Test string conversion.""" + metadata = RequestMetadata() + result = FluentResult("test-value", metadata) + assert str(result) == "test-value" + + result_list = FluentResult(["a", "b"], metadata) + assert str(result_list) == "['a', 'b']" + + def test_repr(self): + """Test repr for debugging.""" + metadata = RequestMetadata() + result = FluentResult("test", metadata) + assert repr(result) == "FluentResult('test')" + + def test_equality_fluent_result(self): + """Test equality between FluentResult instances.""" + metadata = RequestMetadata() + result1 = FluentResult("same", metadata) + result2 = FluentResult("same", metadata) + result3 = FluentResult("different", metadata) + assert result1 == result2 + assert result1 != result3 + + def test_equality_raw_value(self): + """Test equality between FluentResult and raw value.""" + metadata = RequestMetadata() + result = FluentResult("test", metadata) + assert result == "test" + assert result != "other" + + result_list = FluentResult([1, 2, 3], metadata) + assert result_list == [1, 2, 3] + assert result_list != [1, 2] + + def test_bool_truthy(self): + """Test bool conversion for truthy results.""" + metadata = RequestMetadata() + assert bool(FluentResult("non-empty", metadata)) is True + assert bool(FluentResult([1, 2], metadata)) is True + assert bool(FluentResult({"key": "value"}, metadata)) is True + + def test_bool_falsy(self): + """Test bool conversion for falsy results.""" + metadata = RequestMetadata() + assert bool(FluentResult("", metadata)) is False + assert bool(FluentResult([], metadata)) is False + assert bool(FluentResult({}, metadata)) is False + assert bool(FluentResult(None, metadata)) is False + + def test_contains_list(self): + """Test 'in' operator for list result.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b", "c"], metadata) + assert "a" in result + assert "b" in result + assert "x" not in result + + def test_contains_dict(self): + """Test 'in' operator for dict result (checks keys).""" + metadata = RequestMetadata() + result = FluentResult({"name": "Contoso", "id": "123"}, metadata) + assert "name" in result + assert "id" in result + assert "unknown" not in result + + def test_contains_string(self): + """Test 'in' operator for string result.""" + metadata = RequestMetadata() + result = FluentResult("hello world", metadata) + assert "hello" in result + assert "world" in result + assert "xyz" not in result + + def test_hash_string(self): + """Test hash for hashable string result.""" + metadata = RequestMetadata() + result = FluentResult("hashable", metadata) + # Should not raise + h = hash(result) + assert isinstance(h, int) + + def test_hash_unhashable_list(self): + """Test hash raises for unhashable list result.""" + metadata = RequestMetadata() + result = FluentResult(["not", "hashable"], metadata) + with pytest.raises(TypeError, match="unhashable type"): + hash(result) + + def test_hash_unhashable_dict(self): + """Test hash raises for unhashable dict result.""" + metadata = RequestMetadata() + result = FluentResult({"not": "hashable"}, metadata) + with pytest.raises(TypeError, match="unhashable type"): + hash(result) + + +class TestLegacyResultTypes: + """Tests for legacy result types to ensure backward compatibility.""" + + def test_operation_result(self): + """Test OperationResult base class.""" + result = OperationResult( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="service-789" + ) + assert result.client_request_id == "client-123" + assert result.correlation_id == "corr-456" + assert result.service_request_id == "service-789" + + def test_create_result(self): + """Test CreateResult with IDs.""" + result = CreateResult( + client_request_id="client-123", + ids=["guid-1", "guid-2"] + ) + assert result.ids == ["guid-1", "guid-2"] + assert result.client_request_id == "client-123" + + def test_update_result(self): + """Test UpdateResult (no additional fields).""" + result = UpdateResult(client_request_id="client-123") + assert result.client_request_id == "client-123" + + def test_delete_result(self): + """Test DeleteResult with bulk job ID.""" + result = DeleteResult( + client_request_id="client-123", + bulk_job_id="job-456" + ) + assert result.bulk_job_id == "job-456" + + def test_get_result(self): + """Test GetResult with record data.""" + record_data = {"accountid": "guid-123", "name": "Contoso"} + result = GetResult( + client_request_id="client-123", + record=record_data + ) + assert result.record == record_data + assert result.record["name"] == "Contoso" + + def test_paged_result(self): + """Test PagedResult for pagination.""" + records = [ + {"id": "1", "name": "A"}, + {"id": "2", "name": "B"} + ] + result = PagedResult( + client_request_id="client-123", + records=records, + page_number=1, + has_more=True + ) + assert result.records == records + assert result.page_number == 1 + assert result.has_more is True + + def test_legacy_types_frozen(self): + """Test that all legacy types are frozen (immutable).""" + result = CreateResult(ids=["guid-1"]) + with pytest.raises(AttributeError): + result.ids = ["new"] # type: ignore + + +class TestFluentResultBackwardCompatibility: + """Tests demonstrating FluentResult backward compatibility with existing code patterns.""" + + def test_list_unpacking(self): + """Test that FluentResult supports list unpacking.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b", "c"], metadata) + first, second, third = result + assert first == "a" + assert second == "b" + assert third == "c" + + def test_loop_iteration(self): + """Test that FluentResult works in for loops.""" + metadata = RequestMetadata() + result = FluentResult(["id1", "id2"], metadata) + collected = [] + for item in result: + collected.append(item) + assert collected == ["id1", "id2"] + + def test_list_conversion(self): + """Test that FluentResult can be converted to list.""" + metadata = RequestMetadata() + result = FluentResult(["a", "b"], metadata) + as_list = list(result) + assert as_list == ["a", "b"] + + def test_slice_access(self): + """Test that FluentResult supports slice access.""" + metadata = RequestMetadata() + result = FluentResult([1, 2, 3, 4, 5], metadata) + assert result[1:3] == [2, 3] + assert result[:2] == [1, 2] + assert result[::2] == [1, 3, 5] + + def test_conditional_check(self): + """Test FluentResult in conditional expressions.""" + metadata = RequestMetadata() + + # Non-empty result + result = FluentResult(["id1"], metadata) + if result: + passed = True + else: + passed = False + assert passed is True + + # Empty result + empty_result = FluentResult([], metadata) + if empty_result: + passed = True + else: + passed = False + assert passed is False + + def test_print_statement(self): + """Test that FluentResult prints correctly.""" + metadata = RequestMetadata() + result = FluentResult("test-guid", metadata) + # This should not raise and should produce readable output + output = str(result) + assert output == "test-guid" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e765ba0..0cb264d 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -7,6 +7,7 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.core.results import RequestMetadata, FluentResult class TestDataverseClient(unittest.TestCase): @@ -23,46 +24,91 @@ def setUp(self): # This ensures we verify logic without making actual HTTP calls self.client._odata = MagicMock() + # Create a sample metadata object for mocking + self.sample_metadata = RequestMetadata( + client_request_id="test-client-123", + correlation_id="test-corr-456", + http_status_code=200, + timing_ms=100.0 + ) + def test_create_single(self): """Test create method with a single record.""" - # Setup mock return values - # _create must return a GUID string - self.client._odata._create.return_value = "00000000-0000-0000-0000-000000000000" - # _entity_set_from_schema_name should return the plural entity set name + # Setup mock return values for _with_metadata variant + self.client._odata._create_with_metadata.return_value = ( + "00000000-0000-0000-0000-000000000000", + self.sample_metadata + ) self.client._odata._entity_set_from_schema_name.return_value = "accounts" # Execute test - self.client.create("account", {"name": "Contoso Ltd"}) + result = self.client.create("account", {"name": "Contoso Ltd"}) - # Verify - # Ensure _entity_set_from_schema_name was called and its result ("accounts") was passed to _create - self.client._odata._create.assert_called_once_with("accounts", "account", {"name": "Contoso Ltd"}) + # Verify - now uses _create_with_metadata + self.client._odata._create_with_metadata.assert_called_once_with( + "accounts", "account", {"name": "Contoso Ltd"} + ) + # Result should be a FluentResult that behaves like a list + self.assertIsInstance(result, FluentResult) + self.assertEqual(result[0], "00000000-0000-0000-0000-000000000000") + self.assertEqual(len(result), 1) def test_create_multiple(self): """Test create method with multiple records.""" payloads = [{"name": "Company A"}, {"name": "Company B"}, {"name": "Company C"}] - # Setup mock return values - # _create_multiple must return a list of GUID strings - self.client._odata._create_multiple.return_value = [ - "00000000-0000-0000-0000-000000000001", - "00000000-0000-0000-0000-000000000002", - "00000000-0000-0000-0000-000000000003", - ] + # Setup mock return values for _with_metadata variant + self.client._odata._create_multiple_with_metadata.return_value = ( + [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003", + ], + self.sample_metadata, + {"total": 3, "success": 3, "failures": 0} + ) self.client._odata._entity_set_from_schema_name.return_value = "accounts" # Execute test - self.client.create("account", payloads) + result = self.client.create("account", payloads) + + # Verify - now uses _create_multiple_with_metadata + self.client._odata._create_multiple_with_metadata.assert_called_once_with( + "accounts", "account", payloads + ) + # Result should be a FluentResult that behaves like a list + self.assertIsInstance(result, FluentResult) + self.assertEqual(len(result), 3) + + def test_create_with_detail_response(self): + """Test that create() supports .with_detail_response() for telemetry.""" + self.client._odata._create_with_metadata.return_value = ( + "00000000-0000-0000-0000-000000000000", + self.sample_metadata + ) + self.client._odata._entity_set_from_schema_name.return_value = "accounts" + + result = self.client.create("account", {"name": "Test"}) + response = result.with_detail_response() - # Verify - self.client._odata._create_multiple.assert_called_once_with("accounts", "account", payloads) + # Verify telemetry is available + self.assertEqual(response.result, ["00000000-0000-0000-0000-000000000000"]) + self.assertEqual(response.telemetry["client_request_id"], "test-client-123") + self.assertEqual(response.telemetry["timing_ms"], 100.0) def test_update_single(self): """Test update method with a single record.""" - self.client.update("account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"}) - self.client._odata._update.assert_called_once_with( + # Setup mock return value for _with_metadata variant + self.client._odata._update_with_metadata.return_value = (None, self.sample_metadata) + + result = self.client.update("account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"}) + + self.client._odata._update_with_metadata.assert_called_once_with( "account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"} ) + # Result should be a FluentResult wrapping None + self.assertIsInstance(result, FluentResult) + self.assertIsNone(result.value) def test_update_multiple(self): """Test update method with multiple records (broadcast).""" @@ -72,13 +118,24 @@ def test_update_multiple(self): ] changes = {"statecode": 1} - self.client.update("account", ids, changes) + result = self.client.update("account", ids, changes) + + # Bulk updates still use _update_by_ids (no _with_metadata variant yet) self.client._odata._update_by_ids.assert_called_once_with("account", ids, changes) + self.assertIsInstance(result, FluentResult) def test_delete_single(self): """Test delete method with a single record.""" - self.client.delete("account", "00000000-0000-0000-0000-000000000000") - self.client._odata._delete.assert_called_once_with("account", "00000000-0000-0000-0000-000000000000") + # Setup mock return value for _with_metadata variant + self.client._odata._delete_with_metadata.return_value = (None, self.sample_metadata) + + result = self.client.delete("account", "00000000-0000-0000-0000-000000000000") + + self.client._odata._delete_with_metadata.assert_called_once_with( + "account", "00000000-0000-0000-0000-000000000000" + ) + # Result should be a FluentResult wrapping None + self.assertIsInstance(result, FluentResult) def test_delete_multiple(self): """Test delete method with multiple records.""" @@ -89,21 +146,39 @@ def test_delete_multiple(self): # Mock return value for bulk delete job ID self.client._odata._delete_multiple.return_value = "job-guid-123" - job_id = self.client.delete("account", ids) + result = self.client.delete("account", ids) self.client._odata._delete_multiple.assert_called_once_with("account", ids) - self.assertEqual(job_id, "job-guid-123") + # Result should be a FluentResult wrapping the job ID + self.assertIsInstance(result, FluentResult) + self.assertEqual(result.value, "job-guid-123") def test_get_single(self): """Test get method with a single record ID.""" - # Setup mock return value + # Setup mock return value for _with_metadata variant + expected_record = {"accountid": "00000000-0000-0000-0000-000000000000", "name": "Contoso"} + self.client._odata._get_with_metadata.return_value = (expected_record, self.sample_metadata) + + result = self.client.get("account", "00000000-0000-0000-0000-000000000000") + + self.client._odata._get_with_metadata.assert_called_once_with( + "account", "00000000-0000-0000-0000-000000000000", select=None + ) + # Result should be a FluentResult that behaves like a dict + self.assertIsInstance(result, FluentResult) + self.assertEqual(result["name"], "Contoso") + + def test_get_single_with_detail_response(self): + """Test that get() for single record supports .with_detail_response().""" expected_record = {"accountid": "00000000-0000-0000-0000-000000000000", "name": "Contoso"} - self.client._odata._get.return_value = expected_record + self.client._odata._get_with_metadata.return_value = (expected_record, self.sample_metadata) result = self.client.get("account", "00000000-0000-0000-0000-000000000000") + response = result.with_detail_response() - self.client._odata._get.assert_called_once_with("account", "00000000-0000-0000-0000-000000000000", select=None) - self.assertEqual(result, expected_record) + # Verify telemetry is available + self.assertEqual(response.result["name"], "Contoso") + self.assertEqual(response.telemetry["http_status_code"], 200) def test_get_multiple(self): """Test get method for querying multiple records."""