Skip to content

Commit 247fae7

Browse files
tpellissierclaude
andcommitted
Change QueryBuilder.execute() to flat iteration by default
execute() now yields individual records instead of pages, abstracting away OData paging. Pass by_page=True for explicit page-level iteration. This follows the abstraction-level heuristic: QueryBuilder is the "abstract away OData" API, so paging should be transparent. The raw records.get() API retains paged iteration for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b7007d commit 247fae7

4 files changed

Lines changed: 134 additions & 84 deletions

File tree

examples/advanced/walkthrough.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -258,58 +258,51 @@ def main():
258258
print("7. QueryBuilder - Fluent Queries")
259259
print("=" * 80)
260260

261-
# Basic fluent query: active records sorted by amount
261+
# Basic fluent query: active records sorted by amount (flat iteration)
262262
log_call("client.query.builder(...).select().filter_eq().order_by().execute()")
263263
print("Querying incomplete records ordered by amount (fluent builder)...")
264-
qb_records = []
265-
for page in backoff(
264+
qb_records = list(backoff(
266265
lambda: client.query.builder(table_name)
267266
.select("new_Title", "new_Amount", "new_Priority")
268267
.filter_eq("new_Completed", False)
269268
.order_by("new_Amount", descending=True)
270269
.top(10)
271270
.execute()
272-
):
273-
qb_records.extend(page)
271+
))
274272
print(f"[OK] QueryBuilder found {len(qb_records)} incomplete records:")
275273
for rec in qb_records[:5]:
276274
print(f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')}")
277275

278276
# filter_in: records with specific priorities
279277
log_call("client.query.builder(...).filter_in('new_Priority', [HIGH, LOW]).execute()")
280278
print("Querying records with HIGH or LOW priority (filter_in)...")
281-
priority_records = []
282-
for page in backoff(
279+
priority_records = list(backoff(
283280
lambda: client.query.builder(table_name)
284281
.select("new_Title", "new_Priority")
285282
.filter_in("new_Priority", [Priority.HIGH, Priority.LOW])
286283
.execute()
287-
):
288-
priority_records.extend(page)
284+
))
289285
print(f"[OK] Found {len(priority_records)} records with HIGH or LOW priority")
290286
for rec in priority_records[:5]:
291287
print(f" - '{rec.get('new_title')}' Priority={rec.get('new_priority')}")
292288

293289
# filter_between: amount in a range
294290
log_call("client.query.builder(...).filter_between('new_Amount', 500, 1500).execute()")
295291
print("Querying records with amount between 500 and 1500 (filter_between)...")
296-
range_records = []
297-
for page in backoff(
292+
range_records = list(backoff(
298293
lambda: client.query.builder(table_name)
299294
.select("new_Title", "new_Amount")
300295
.filter_between("new_Amount", 500, 1500)
301296
.execute()
302-
):
303-
range_records.extend(page)
297+
))
304298
print(f"[OK] Found {len(range_records)} records with amount in [500, 1500]")
305299
for rec in range_records:
306300
print(f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')}")
307301

308302
# Composable expression tree with where()
309303
log_call("client.query.builder(...).where((eq(...) | eq(...)) & gt(...)).execute()")
310304
print("Querying with composable expression tree (where)...")
311-
expr_records = []
312-
for page in backoff(
305+
expr_records = list(backoff(
313306
lambda: client.query.builder(table_name)
314307
.select("new_Title", "new_Amount", "new_Quantity")
315308
.where(
@@ -318,16 +311,15 @@ def main():
318311
.order_by("new_Amount", descending=True)
319312
.top(5)
320313
.execute()
321-
):
322-
expr_records.extend(page)
314+
))
323315
print(f"[OK] Expression tree query found {len(expr_records)} records:")
324316
for rec in expr_records:
325317
print(
326318
f" - '{rec.get('new_title')}' Amount={rec.get('new_amount')} Qty={rec.get('new_quantity')}"
327319
)
328320

329-
# Combined: fluent filters + expression tree + paging
330-
log_call("client.query.builder(...).filter_eq().where(between()).page_size().execute()")
321+
# Combined: fluent filters + expression tree + paging (by_page=True)
322+
log_call("client.query.builder(...).filter_eq().where(between()).page_size().execute(by_page=True)")
331323
print("Querying with combined fluent + expression filters and paging...")
332324
combined_page_count = 0
333325
combined_record_count = 0
@@ -338,7 +330,7 @@ def main():
338330
.where(between("new_Quantity", 1, 15))
339331
.order_by("new_Quantity")
340332
.page_size(3)
341-
.execute()
333+
.execute(by_page=True)
342334
):
343335
combined_page_count += 1
344336
combined_record_count += len(page)

src/PowerPlatform/Dataverse/models/query_builder.py

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,32 @@
99
1010
Example::
1111
12-
# Via client (recommended)
13-
for page in (client.query.builder("account")
14-
.select("name", "revenue")
15-
.filter_eq("statecode", 0)
16-
.filter_gt("revenue", 1000000)
17-
.order_by("revenue", descending=True)
18-
.top(100)
19-
.page_size(50)
20-
.execute()):
21-
for record in page:
22-
print(record["name"])
12+
# Via client (recommended) -- flat iteration over records
13+
for record in (client.query.builder("account")
14+
.select("name", "revenue")
15+
.filter_eq("statecode", 0)
16+
.filter_gt("revenue", 1000000)
17+
.order_by("revenue", descending=True)
18+
.top(100)
19+
.execute()):
20+
print(record["name"])
2321
2422
# With composable expression tree
2523
from PowerPlatform.Dataverse.models.filters import eq, gt
2624
25+
for record in (client.query.builder("account")
26+
.select("name", "revenue")
27+
.where((eq("statecode", 0) | eq("statecode", 1))
28+
& gt("revenue", 100000))
29+
.top(100)
30+
.execute()):
31+
print(record["name"])
32+
33+
# Opt-in paged iteration (for batch processing)
2734
for page in (client.query.builder("account")
28-
.select("name", "revenue")
29-
.where((eq("statecode", 0) | eq("statecode", 1))
30-
& gt("revenue", 100000))
31-
.top(100)
32-
.execute()):
33-
for record in page:
34-
print(record["name"])
35+
.select("name")
36+
.execute(by_page=True)):
37+
process_batch(page)
3538
"""
3639

3740
from __future__ import annotations
@@ -394,28 +397,42 @@ def build(self) -> dict:
394397

395398
# --------------------------------------------------------------- execute
396399

397-
def execute(self) -> Iterable[List[Dict[str, Any]]]:
398-
"""Execute the query and return paginated results.
400+
def execute(self, *, by_page: bool = False) -> Union[Iterable[Dict[str, Any]], Iterable[List[Dict[str, Any]]]]:
401+
"""Execute the query and return results.
402+
403+
By default, returns a flat iterator over individual records,
404+
abstracting away OData paging. Pass ``by_page=True`` to get
405+
page-level iteration instead (useful for batch processing).
399406
400407
This method is only available when the QueryBuilder was created
401408
via ``client.query.builder(table)``. Standalone ``QueryBuilder``
402409
instances should use :meth:`build` to get parameters and pass them
403410
to ``client.records.get()`` manually.
404411
405-
:return: Generator yielding pages, where each page is a list of
406-
record dictionaries.
407-
:rtype: Iterable[List[Dict[str, Any]]]
412+
:param by_page: If ``True``, yield pages (lists of record dicts)
413+
instead of individual records. Defaults to ``False``.
414+
:type by_page: bool
415+
:return: Generator yielding individual record dicts (default) or
416+
pages of record dicts (when ``by_page=True``).
417+
:rtype: Iterable[Dict[str, Any]] or Iterable[List[Dict[str, Any]]]
408418
:raises RuntimeError: If the query was not created via
409419
``client.query.builder()``.
410420
411-
Example::
421+
Example:
422+
Flat iteration (default)::
412423
413-
for page in (client.query.builder("account")
414-
.select("name")
415-
.filter_eq("statecode", 0)
416-
.execute()):
417-
for record in page:
424+
for record in (client.query.builder("account")
425+
.select("name")
426+
.filter_eq("statecode", 0)
427+
.execute()):
418428
print(record["name"])
429+
430+
Paged iteration::
431+
432+
for page in (client.query.builder("account")
433+
.select("name")
434+
.execute(by_page=True)):
435+
process_batch(page)
419436
"""
420437
if self._query_ops is None:
421438
raise RuntimeError(
@@ -437,4 +454,11 @@ def _paged() -> Iterable[List[Dict[str, Any]]]:
437454
page_size=params.get("page_size"),
438455
)
439456

440-
return _paged()
457+
if by_page:
458+
return _paged()
459+
460+
def _flat() -> Iterable[Dict[str, Any]]:
461+
for page in _paged():
462+
yield from page
463+
464+
return _flat()

tests/unit/models/test_query_builder.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -452,22 +452,44 @@ def test_execute_calls_get_multiple(self):
452452
page_size=50,
453453
)
454454

455-
def test_execute_returns_result(self):
455+
def test_execute_returns_flat_records_by_default(self):
456456
mock_query_ops = MagicMock()
457457
mock_client = mock_query_ops._client
458458
mock_odata = MagicMock()
459459
mock_client._scoped_odata.return_value.__enter__ = MagicMock(return_value=mock_odata)
460460
mock_client._scoped_odata.return_value.__exit__ = MagicMock(return_value=False)
461461

462-
expected = [{"name": "Test"}]
463-
mock_odata._get_multiple.return_value = iter([expected])
462+
mock_odata._get_multiple.return_value = iter(
463+
[[{"name": "A"}, {"name": "B"}], [{"name": "C"}]]
464+
)
465+
466+
qb = QueryBuilder("account")
467+
qb._query_ops = mock_query_ops
468+
records = list(qb.execute())
469+
470+
self.assertEqual(len(records), 3)
471+
self.assertEqual(records[0]["name"], "A")
472+
self.assertEqual(records[1]["name"], "B")
473+
self.assertEqual(records[2]["name"], "C")
474+
475+
def test_execute_by_page_returns_pages(self):
476+
mock_query_ops = MagicMock()
477+
mock_client = mock_query_ops._client
478+
mock_odata = MagicMock()
479+
mock_client._scoped_odata.return_value.__enter__ = MagicMock(return_value=mock_odata)
480+
mock_client._scoped_odata.return_value.__exit__ = MagicMock(return_value=False)
481+
482+
page1 = [{"name": "A"}, {"name": "B"}]
483+
page2 = [{"name": "C"}]
484+
mock_odata._get_multiple.return_value = iter([page1, page2])
464485

465486
qb = QueryBuilder("account")
466487
qb._query_ops = mock_query_ops
467-
pages = list(qb.execute())
488+
pages = list(qb.execute(by_page=True))
468489

469-
self.assertEqual(len(pages), 1)
470-
self.assertEqual(pages[0], expected)
490+
self.assertEqual(len(pages), 2)
491+
self.assertEqual(pages[0], page1)
492+
self.assertEqual(pages[1], page2)
471493

472494
def test_execute_passes_none_for_empty_options(self):
473495
mock_query_ops = MagicMock()

tests/unit/test_query_operations.py

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ def test_builder_returns_query_builder(self):
6363
self.assertEqual(qb.table, "account")
6464
self.assertIs(qb._query_ops, self.client.query)
6565

66-
def test_builder_execute_basic(self):
67-
"""builder().execute() should call _get_multiple with built params."""
68-
expected_page = [{"accountid": "1", "name": "Test"}]
69-
self.client._odata._get_multiple.return_value = iter([expected_page])
66+
def test_builder_execute_flat_default(self):
67+
"""builder().execute() should return flat records by default."""
68+
self.client._odata._get_multiple.return_value = iter(
69+
[[{"accountid": "1", "name": "Test"}]]
70+
)
7071

71-
pages = list(
72+
records = list(
7273
self.client.query.builder("account")
7374
.select("name")
7475
.filter_eq("statecode", 0)
@@ -85,8 +86,34 @@ def test_builder_execute_basic(self):
8586
expand=None,
8687
page_size=None,
8788
)
88-
self.assertEqual(len(pages), 1)
89-
self.assertEqual(pages[0], expected_page)
89+
self.assertEqual(len(records), 1)
90+
self.assertEqual(records[0]["name"], "Test")
91+
92+
def test_builder_execute_flat_multiple_pages(self):
93+
"""execute() should flatten records from multiple pages."""
94+
self.client._odata._get_multiple.return_value = iter(
95+
[[{"accountid": "1"}], [{"accountid": "2"}]]
96+
)
97+
98+
records = list(self.client.query.builder("account").execute())
99+
100+
self.assertEqual(len(records), 2)
101+
self.assertEqual(records[0]["accountid"], "1")
102+
self.assertEqual(records[1]["accountid"], "2")
103+
104+
def test_builder_execute_by_page(self):
105+
"""execute(by_page=True) should yield pages."""
106+
page1 = [{"accountid": "1"}]
107+
page2 = [{"accountid": "2"}]
108+
self.client._odata._get_multiple.return_value = iter([page1, page2])
109+
110+
pages = list(
111+
self.client.query.builder("account").execute(by_page=True)
112+
)
113+
114+
self.assertEqual(len(pages), 2)
115+
self.assertEqual(pages[0], page1)
116+
self.assertEqual(pages[1], page2)
90117

91118
def test_builder_execute_all_params(self):
92119
"""builder().execute() should forward all parameters."""
@@ -114,20 +141,6 @@ def test_builder_execute_all_params(self):
114141
page_size=25,
115142
)
116143

117-
def test_builder_execute_multiple_pages(self):
118-
"""builder().execute() should yield multiple pages."""
119-
page1 = [{"accountid": "1"}]
120-
page2 = [{"accountid": "2"}]
121-
self.client._odata._get_multiple.return_value = iter([page1, page2])
122-
123-
pages = list(
124-
self.client.query.builder("account").execute()
125-
)
126-
127-
self.assertEqual(len(pages), 2)
128-
self.assertEqual(pages[0], page1)
129-
self.assertEqual(pages[1], page2)
130-
131144
def test_builder_execute_with_where(self):
132145
"""builder().where().execute() should compile expression to filter."""
133146
from PowerPlatform.Dataverse.models.filters import eq, gt
@@ -148,13 +161,13 @@ def test_builder_execute_with_where(self):
148161

149162
def test_builder_full_fluent_workflow(self):
150163
"""End-to-end test of the fluent query workflow."""
151-
expected_page = [
164+
expected_records = [
152165
{"accountid": "1", "name": "Big Corp", "revenue": 5000000},
153166
{"accountid": "2", "name": "Mega Inc", "revenue": 4000000},
154167
]
155-
self.client._odata._get_multiple.return_value = iter([expected_page])
168+
self.client._odata._get_multiple.return_value = iter([expected_records])
156169

157-
pages = list(
170+
records = list(
158171
self.client.query.builder("account")
159172
.select("name", "revenue")
160173
.filter_eq("statecode", 0)
@@ -166,10 +179,9 @@ def test_builder_full_fluent_workflow(self):
166179
.execute()
167180
)
168181

169-
self.assertEqual(len(pages), 1)
170-
self.assertEqual(len(pages[0]), 2)
171-
self.assertEqual(pages[0][0]["name"], "Big Corp")
172-
self.assertEqual(pages[0][1]["name"], "Mega Inc")
182+
self.assertEqual(len(records), 2)
183+
self.assertEqual(records[0]["name"], "Big Corp")
184+
self.assertEqual(records[1]["name"], "Mega Inc")
173185

174186

175187
if __name__ == "__main__":

0 commit comments

Comments
 (0)