Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .claude/skills/dataverse-sdk-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,41 @@ for record in results:
print(record["name"])
```

### FetchXML Queries

Execute FetchXML queries with automatic paging cookie handling. FetchXML must contain `<fetch><entity name='...'>`. Returns an iterable of pages.

```python
# Basic FetchXML query
fetchxml = """
<fetch top='5'>
<entity name='account'>
<attribute name='name' />
</entity>
</fetch>
"""
for page in client.query.fetchxml(fetchxml):
for record in page:
print(record["name"])

# FetchXML with filter and paging
fetchxml = """
<fetch>
<entity name='contact'>
<attribute name='fullname' />
<attribute name='jobtitle' />
<order attribute='fullname' descending='true' />
<filter type='and'>
<condition attribute='statecode' operator='eq' value='0' />
</filter>
</entity>
</fetch>
"""
for page in client.query.fetchxml(fetchxml, page_size=50):
for record in page:
print(record["fullname"])
```

### Table Management

#### Create Custom Tables
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
- [Bulk operations](#bulk-operations)
- [Upsert operations](#upsert-operations)
- [Query data](#query-data)
- [FetchXML queries](#fetchxml-queries)
- [Table management](#table-management)
- [Relationship management](#relationship-management)
- [File operations](#file-operations)
Expand All @@ -36,7 +37,8 @@ A Python client library for Microsoft Dataverse that provides a unified interfac

- **🔄 CRUD Operations**: Create, read, update, and delete records with support for bulk operations and automatic retry
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, `UpsertMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
- **📋 FetchXML Queries**: Execute FetchXML queries with automatic paging cookie handling via `client.query.fetchxml()`
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
- **📎 File Operations**: Upload files to Dataverse file columns with automatic chunking for large files
Expand Down Expand Up @@ -268,6 +270,48 @@ for page in client.records.get(
> - **`expand`**: Navigation property names are case-sensitive and must match the exact server names
> - **`select`** and **`orderby`**: Case-insensitive; automatically converted to lowercase

### FetchXML queries

```python
# Basic FetchXML query
fetchxml = """
<fetch top='5'>
<entity name='account'>
<attribute name='name' />
</entity>
</fetch>
"""
for page in client.query.fetchxml(fetchxml):
for record in page:
print(record["name"])

# FetchXML with filter and paging
fetchxml = """
<fetch>
<entity name='contact'>
<attribute name='fullname' />
<attribute name='jobtitle' />
<order attribute='fullname' descending='true' />
<filter type='and'>
<condition attribute='statecode' operator='eq' value='0' />
</filter>
</entity>
</fetch>
"""
for page in client.query.fetchxml(fetchxml, page_size=50):
for record in page:
print(record["fullname"])
```

> **FetchXML limitations:**
> - FetchXML queries are sent as URL-encoded GET parameters. Very large FetchXML strings may exceed the 32KB URL limit.
> - Do not use the `top` attribute with paging (`count`/`page`) — they are incompatible.
> - Aggregate queries (`aggregate='true'`) return a single result set and do not support paging. Aggregates are limited to 50,000 records.
> - Maximum of 15 `link-entity` (join) elements per query.
> - Maximum of 500 total `condition` and `link-entity` elements combined.
> - For consistent paging results, include an `<order>` element with a unique column (e.g., primary key).
> - Queries with `distinct='true'` require at least one `<order>` element.

### Table management

```python
Expand Down
5 changes: 5 additions & 0 deletions src/PowerPlatform/Dataverse/core/_error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
# Validation subcodes
VALIDATION_SQL_NOT_STRING = "validation_sql_not_string"
VALIDATION_SQL_EMPTY = "validation_sql_empty"
VALIDATION_FETCHXML_NOT_STRING = "validation_fetchxml_not_string"
VALIDATION_FETCHXML_EMPTY = "validation_fetchxml_empty"
VALIDATION_FETCHXML_MALFORMED = "validation_fetchxml_malformed"
VALIDATION_FETCHXML_TOO_LONG = "validation_fetchxml_too_long"
VALIDATION_FETCHXML_INVALID_PAGE_SIZE = "validation_fetchxml_invalid_page_size"
VALIDATION_ENUM_NO_MEMBERS = "validation_enum_no_members"
VALIDATION_ENUM_NON_INT_VALUE = "validation_enum_non_int_value"
VALIDATION_UNSUPPORTED_COLUMN_TYPE = "validation_unsupported_column_type"
Expand Down
201 changes: 201 additions & 0 deletions src/PowerPlatform/Dataverse/data/_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
import re
import json
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
import importlib.resources as ir
from contextlib import contextmanager
from contextvars import ContextVar
from urllib.parse import quote as _url_encode, unquote as _url_decode

from ..core._http import _HttpClient
from ._upload import _FileUploadMixin
Expand All @@ -27,6 +29,11 @@
_is_transient_status,
VALIDATION_SQL_NOT_STRING,
VALIDATION_SQL_EMPTY,
VALIDATION_FETCHXML_NOT_STRING,
VALIDATION_FETCHXML_EMPTY,
VALIDATION_FETCHXML_MALFORMED,
VALIDATION_FETCHXML_TOO_LONG,
VALIDATION_FETCHXML_INVALID_PAGE_SIZE,
METADATA_ENTITYSET_NOT_FOUND,
METADATA_ENTITYSET_NAME_MISSING,
METADATA_TABLE_NOT_FOUND,
Expand All @@ -41,6 +48,8 @@
_GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
_CALL_SCOPE_CORRELATION_ID: ContextVar[Optional[str]] = ContextVar("_CALL_SCOPE_CORRELATION_ID", default=None)
_DEFAULT_EXPECTED_STATUSES: tuple[int, ...] = (200, 201, 202, 204)
_MAX_FETCHXML_LENGTH = 16_000 # ~16 KB raw; caps input size to mitigate XML entity expansion attacks; after URL-encoding stays under 32 KB GET URL limit
_MAX_FETCHXML_PAGES = 10_000


@dataclass
Expand Down Expand Up @@ -852,6 +861,198 @@ def _extract_logical_table(sql: str) -> str:
raise ValueError("Unable to determine table logical name from SQL (expected 'FROM <name>').")
return m.group(1).lower()

# ---------------------- FetchXML -------------------------
@staticmethod
def _extract_entity_from_fetchxml_element(root: ET.Element) -> str:
"""Extract entity logical name from <entity name='...'> in a parsed FetchXML root element."""
local_tag = root.tag.split("}")[-1] if "}" in root.tag else root.tag
if local_tag != "fetch":
raise ValidationError(
"Invalid FetchXML: root element must be <fetch>",
subcode=VALIDATION_FETCHXML_MALFORMED,
)
entity_elem = None
for child in root:
child_local = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if child_local == "entity":
entity_elem = child
break
if entity_elem is None:
raise ValidationError(
"Invalid FetchXML: missing <entity> element",
subcode=VALIDATION_FETCHXML_MALFORMED,
)
name = entity_elem.get("name")
if not name or not str(name).strip():
raise ValidationError(
"Invalid FetchXML: <entity> must have a name attribute",
subcode=VALIDATION_FETCHXML_MALFORMED,
)
return str(name).strip().lower()

@staticmethod
def _extract_entity_from_fetchxml(fetchxml: str) -> str:
"""Extract entity logical name from <entity name='...'> in FetchXML."""
try:
root = ET.fromstring(fetchxml.strip())
except ET.ParseError as e:
raise ValidationError(
f"Invalid FetchXML: malformed XML ({e})",
subcode=VALIDATION_FETCHXML_MALFORMED,
) from e
return _ODataClient._extract_entity_from_fetchxml_element(root)

def _query_fetchxml(
self,
fetchxml: str,
*,
page_size: Optional[int] = None,
) -> Iterable[List[Dict[str, Any]]]:
"""Execute a FetchXML query with automatic paging cookie handling."""
if not isinstance(fetchxml, str):
raise ValidationError(
"fetchxml must be a string",
subcode=VALIDATION_FETCHXML_NOT_STRING,
)
if not fetchxml.strip():
raise ValidationError(
"fetchxml must be a non-empty string",
subcode=VALIDATION_FETCHXML_EMPTY,
)

if page_size is not None:
if isinstance(page_size, bool) or not isinstance(page_size, int):
raise ValidationError(
"page_size must be an integer",
subcode=VALIDATION_FETCHXML_INVALID_PAGE_SIZE,
)
ps = page_size
if ps <= 0:
raise ValidationError(
"page_size must be a positive integer",
subcode=VALIDATION_FETCHXML_INVALID_PAGE_SIZE,
)

if len(fetchxml) > _MAX_FETCHXML_LENGTH:
raise ValidationError(
f"FetchXML string exceeds maximum supported length ({_MAX_FETCHXML_LENGTH} characters)",
subcode=VALIDATION_FETCHXML_TOO_LONG,
)

try:
root = ET.fromstring(fetchxml.strip())
except ET.ParseError as e:
raise ValidationError(
f"Invalid FetchXML: malformed XML ({e})",
subcode=VALIDATION_FETCHXML_MALFORMED,
) from e

entity_name = self._extract_entity_from_fetchxml_element(root)
entity_set = self._entity_set_from_schema_name(entity_name)

has_top = root.get("top") is not None
is_aggregate = (root.get("aggregate") or "").lower() == "true"

if has_top and page_size is not None:
raise ValidationError(
"Cannot use page_size with FetchXML that has a 'top' attribute. "
"The 'top' and paging (count/page) attributes are incompatible.",
subcode=VALIDATION_FETCHXML_INVALID_PAGE_SIZE,
)

if is_aggregate and page_size is not None:
raise ValidationError(
"Cannot use page_size with aggregate FetchXML queries. "
"Aggregate queries return a single result set and do not support paging.",
subcode=VALIDATION_FETCHXML_INVALID_PAGE_SIZE,
)

if not is_aggregate and not has_top:
is_distinct = (root.get("distinct") or "").lower() == "true"
if is_distinct:
has_order = any(
(child.tag.split("}")[-1] if "}" in child.tag else child.tag) == "order"
for entity_elem in root
if (entity_elem.tag.split("}")[-1] if "}" in entity_elem.tag else entity_elem.tag) == "entity"
for child in entity_elem
)
if not has_order:
raise ValidationError(
"FetchXML with distinct='true' requires at least one <order> element for consistent paging results",
subcode=VALIDATION_FETCHXML_MALFORMED,
)
if page_size is not None and root.get("count") is None:
root.set("count", str(page_size))
if root.get("page") is None:
root.set("page", "1")

headers = {
"Prefer": 'odata.include-annotations="Microsoft.Dynamics.CRM.fetchxmlpagingcookie,Microsoft.Dynamics.CRM.morerecords"'
}

def _do_request(url: str) -> Dict[str, Any]:
r = self._request("get", url, headers=headers)
try:
return r.json()
except ValueError:
return {}

page_count = 0
while True:
page_count += 1
if page_count > _MAX_FETCHXML_PAGES:
raise ValidationError(
f"FetchXML paging exceeded maximum page limit ({_MAX_FETCHXML_PAGES}). "
"This may indicate a query returning too many results or a paging loop.",
subcode=VALIDATION_FETCHXML_MALFORMED,
)
fetchxml_str = ET.tostring(root, encoding="unicode")
encoded = _url_encode(fetchxml_str, safe="")
url = f"{self.api}/{entity_set}?fetchXml={encoded}"
data = _do_request(url)

items = data.get("value") if isinstance(data, dict) else None
if isinstance(items, list) and items:
yield [x for x in items if isinstance(x, dict)]

if has_top or is_aggregate:
break

more_records = False
paging_cookie_raw = None

if isinstance(data, dict):
more_records_raw = data.get("@Microsoft.Dynamics.CRM.morerecords")
more_records = more_records_raw is True or (
isinstance(more_records_raw, str) and more_records_raw.lower() == "true"
)
paging_cookie_raw = data.get("@Microsoft.Dynamics.CRM.fetchxmlpagingcookie")

if not more_records:
break

if paging_cookie_raw:
try:
cookie_elem = ET.fromstring(paging_cookie_raw)
except ET.ParseError as e:
raise ValidationError(
f"Failed to parse FetchXML paging cookie from server response: {e}",
subcode=VALIDATION_FETCHXML_MALFORMED,
) from e

pagingcookie_value = cookie_elem.get("pagingcookie")
if not pagingcookie_value:
raise ValidationError(
"Paging cookie returned by server is missing 'pagingcookie' attribute",
subcode=VALIDATION_FETCHXML_MALFORMED,
)

decoded = _url_decode(_url_decode(pagingcookie_value))
root.set("paging-cookie", decoded)

current_page = int(root.get("page", "1"))
root.set("page", str(current_page + 1))

# ---------------------- Entity set resolution -----------------------
def _entity_set_from_schema_name(self, table_schema_name: str) -> str:
"""Resolve entity set name (plural) from a schema name (singular) name using metadata.
Expand Down
Loading