Skip to content

Commit afd1fff

Browse files
committed
Enhance FetchXML handling: validate page attribute and update documentation to reflect Record object usage
1 parent 8832d1a commit afd1fff

4 files changed

Lines changed: 69 additions & 13 deletions

File tree

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
_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}")
4949
_CALL_SCOPE_CORRELATION_ID: ContextVar[Optional[str]] = ContextVar("_CALL_SCOPE_CORRELATION_ID", default=None)
5050
_DEFAULT_EXPECTED_STATUSES: tuple[int, ...] = (200, 201, 202, 204)
51-
_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
51+
_MAX_FETCHXML_LENGTH = (
52+
16_000 # ~16 KB raw; caps input size to mitigate XML entity expansion attacks and reduce URL length after encoding
53+
)
5254
_MAX_FETCHXML_PAGES = 10_000
5355

5456

@@ -999,7 +1001,18 @@ def _query_fetchxml(
9991001
)
10001002
if page_size is not None and root.get("count") is None:
10011003
root.set("count", str(page_size))
1002-
if root.get("page") is None:
1004+
page_attr = root.get("page")
1005+
if page_attr is not None:
1006+
try:
1007+
page_number = int(page_attr)
1008+
except ValueError:
1009+
page_number = -1
1010+
if page_number <= 0:
1011+
raise ValidationError(
1012+
f"FetchXML 'page' attribute must be a positive integer, got '{page_attr}'",
1013+
subcode=VALIDATION_FETCHXML_MALFORMED,
1014+
)
1015+
else:
10031016
root.set("page", "1")
10041017

10051018
headers = {

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ def fetchxml(
9090
9191
Executes a FetchXML query against Dataverse via the Web API. The FetchXML
9292
string must contain a root ``<fetch>`` element with a child ``<entity name='...'>``
93-
element. Results are yielded as pages (lists of record dicts).
93+
element. Results are yielded as pages (lists of
94+
:class:`~PowerPlatform.Dataverse.models.record.Record` objects).
9495
9596
:param fetchxml: Raw FetchXML string (e.g. ``<fetch><entity name='account'>...</entity></fetch>``).
9697
:type fetchxml: :class:`str`
@@ -102,8 +103,10 @@ def fetchxml(
102103
If the FetchXML already contains a ``count`` attribute, the ``page_size`` parameter
103104
is ignored. The ``count`` in FetchXML takes precedence.
104105
105-
:return: Generator yielding pages, where each page is a list of record dicts.
106-
:rtype: :class:`~typing.Iterable` of :class:`list` of :class:`dict`
106+
:return: Generator yielding pages, where each page is a list of
107+
:class:`~PowerPlatform.Dataverse.models.record.Record` objects.
108+
:rtype: :class:`~typing.Iterable` of :class:`list` of
109+
:class:`~PowerPlatform.Dataverse.models.record.Record`
107110
108111
:raises ~PowerPlatform.Dataverse.core.errors.ValidationError:
109112
If ``fetchxml`` is not a string, is empty, or has invalid structure.
@@ -141,6 +144,10 @@ def fetchxml(
141144

142145
def _paged() -> Iterable[List[Record]]:
143146
with self._client._scoped_odata() as od:
144-
yield from od._query_fetchxml(fetchxml, page_size=page_size)
147+
entity_name = ""
148+
for page in od._query_fetchxml(fetchxml, page_size=page_size):
149+
if not entity_name:
150+
entity_name = od._extract_entity_from_fetchxml(fetchxml)
151+
yield [Record.from_api_response(entity_name, row) for row in page]
145152

146153
return _paged()

tests/unit/data/test_fetchxml.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def test_query_fetchxml_multi_page(self):
193193
def test_query_fetchxml_paging_cookie_decode(self):
194194
"""Verify double URL-decode of paging cookie and injection into fetch element."""
195195
self._setup_entity_set()
196-
from urllib.parse import quote, unquote
196+
from urllib.parse import quote
197197

198198
# Cookie value is double-encoded
199199
inner = "<cookie pagenumber='2' />"
@@ -264,6 +264,31 @@ def test_query_fetchxml_existing_page_preserved(self):
264264
self.assertIn("page", call_url)
265265
self.assertIn("3", call_url)
266266

267+
def test_query_fetchxml_page_non_integer_raises(self):
268+
"""Raise ValidationError when page attribute is not an integer."""
269+
self._setup_entity_set()
270+
fetchxml = "<fetch page='abc'><entity name='account'><attribute name='name' /></entity></fetch>"
271+
with self.assertRaises(ValidationError) as ctx:
272+
list(self.od._query_fetchxml(fetchxml))
273+
self.assertEqual(ctx.exception.subcode, VALIDATION_FETCHXML_MALFORMED)
274+
self.assertIn("page", str(ctx.exception).lower())
275+
276+
def test_query_fetchxml_page_zero_raises(self):
277+
"""Raise ValidationError when page attribute is zero."""
278+
self._setup_entity_set()
279+
fetchxml = "<fetch page='0'><entity name='account'><attribute name='name' /></entity></fetch>"
280+
with self.assertRaises(ValidationError) as ctx:
281+
list(self.od._query_fetchxml(fetchxml))
282+
self.assertEqual(ctx.exception.subcode, VALIDATION_FETCHXML_MALFORMED)
283+
284+
def test_query_fetchxml_page_negative_raises(self):
285+
"""Raise ValidationError when page attribute is negative."""
286+
self._setup_entity_set()
287+
fetchxml = "<fetch page='-1'><entity name='account'><attribute name='name' /></entity></fetch>"
288+
with self.assertRaises(ValidationError) as ctx:
289+
list(self.od._query_fetchxml(fetchxml))
290+
self.assertEqual(ctx.exception.subcode, VALIDATION_FETCHXML_MALFORMED)
291+
267292
def test_query_fetchxml_morerecords_string_true(self):
268293
"""When morerecords is string 'true' (not boolean), paging continues."""
269294
self._setup_entity_set()

tests/unit/test_query_operations.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,27 @@ def test_sql_empty_result(self):
5858
# -------------------------------------------------------------- fetchxml
5959

6060
def test_fetchxml_basic(self):
61-
"""fetchxml() should call _query_fetchxml and return results."""
62-
expected_pages = [
61+
"""fetchxml() should call _query_fetchxml and return Record objects."""
62+
raw_pages = [
6363
[{"accountid": "1", "name": "Contoso"}, {"accountid": "2", "name": "Fabrikam"}],
6464
]
65-
self.client._odata._query_fetchxml.return_value = iter(expected_pages)
65+
self.client._odata._query_fetchxml.return_value = iter(raw_pages)
66+
self.client._odata._extract_entity_from_fetchxml.return_value = "account"
6667

6768
fetchxml = "<fetch><entity name='account'><attribute name='name' /></entity></fetch>"
6869
result = list(self.client.query.fetchxml(fetchxml))
6970

7071
self.client._odata._query_fetchxml.assert_called_once_with(fetchxml, page_size=None)
71-
self.assertEqual(result, expected_pages)
72+
self.assertEqual(len(result), 1)
73+
self.assertEqual(len(result[0]), 2)
74+
self.assertIsInstance(result[0][0], Record)
75+
self.assertEqual(result[0][0]["name"], "Contoso")
76+
self.assertEqual(result[0][1]["name"], "Fabrikam")
7277

7378
def test_fetchxml_with_page_size(self):
7479
"""fetchxml() should pass page_size through to _query_fetchxml."""
7580
self.client._odata._query_fetchxml.return_value = iter([])
81+
self.client._odata._extract_entity_from_fetchxml.return_value = "account"
7682

7783
fetchxml = "<fetch><entity name='account' /></fetch>"
7884
list(self.client.query.fetchxml(fetchxml, page_size=50))
@@ -82,21 +88,26 @@ def test_fetchxml_with_page_size(self):
8288
def test_fetchxml_empty_result(self):
8389
"""fetchxml() should return empty generator when no results."""
8490
self.client._odata._query_fetchxml.return_value = iter([])
91+
self.client._odata._extract_entity_from_fetchxml.return_value = "account"
8592

8693
fetchxml = "<fetch><entity name='account' /></fetch>"
8794
result = list(self.client.query.fetchxml(fetchxml))
8895

8996
self.assertEqual(result, [])
9097

9198
def test_fetchxml_returns_iterable(self):
92-
"""fetchxml() should return an iterable (generator)."""
99+
"""fetchxml() should return an iterable of Record pages."""
93100
self.client._odata._query_fetchxml.return_value = iter([[{"name": "A"}]])
101+
self.client._odata._extract_entity_from_fetchxml.return_value = "account"
94102

95103
fetchxml = "<fetch><entity name='account' /></fetch>"
96104
result = self.client.query.fetchxml(fetchxml)
97105

98106
self.assertIsNotNone(iter(result))
99-
self.assertEqual(list(result), [[{"name": "A"}]])
107+
pages = list(result)
108+
self.assertEqual(len(pages), 1)
109+
self.assertIsInstance(pages[0][0], Record)
110+
self.assertEqual(pages[0][0]["name"], "A")
100111

101112

102113
if __name__ == "__main__":

0 commit comments

Comments
 (0)