Skip to content

Commit fc590c2

Browse files
author
Samson Gebre
committed
Enhance QueryResult class with list-like behavior and add contract tests
1 parent 65aac7d commit fc590c2

3 files changed

Lines changed: 111 additions & 8 deletions

File tree

src/PowerPlatform/Dataverse/models/record.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77

88
from dataclasses import dataclass, field
9-
from typing import Any, Dict, Iterator, KeysView, List, Optional, ValuesView, ItemsView
9+
from typing import Any, Dict, Iterator, KeysView, List, Optional, Union, ValuesView, ItemsView
1010

1111
__all__ = ["Record", "QueryResult"]
1212

@@ -141,7 +141,7 @@ def __bool__(self) -> bool:
141141
def __repr__(self) -> str:
142142
return f"QueryResult({len(self.records)} records)"
143143

144-
def __getitem__(self, index):
144+
def __getitem__(self, index: Union[int, slice]) -> Union[Record, "QueryResult"]:
145145
result = self.records[index]
146146
return QueryResult(result) if isinstance(index, slice) else result
147147

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ class QueryOperations:
3333
3434
Example::
3535
36+
from PowerPlatform.Dataverse.models.filters import col
37+
3638
client = DataverseClient(base_url, credential)
3739
3840
# Fluent query builder (recommended)
3941
for record in (client.query.builder("account")
4042
.select("name", "revenue")
41-
.filter_eq("statecode", 0)
43+
.where(col("statecode") == 0)
4244
.order_by("revenue", descending=True)
4345
.top(100)
4446
.execute()):
@@ -70,10 +72,12 @@ def builder(self, table: str) -> QueryBuilder:
7072
Example:
7173
Build and execute a query fluently::
7274
75+
from PowerPlatform.Dataverse.models.filters import col
76+
7377
for record in (client.query.builder("account")
7478
.select("name", "revenue")
75-
.filter_eq("statecode", 0)
76-
.filter_gt("revenue", 1000000)
79+
.where(col("statecode") == 0)
80+
.where(col("revenue") > 1_000_000)
7781
.order_by("revenue", descending=True)
7882
.top(100)
7983
.page_size(50)
@@ -82,11 +86,11 @@ def builder(self, table: str) -> QueryBuilder:
8286
8387
With composable expression tree::
8488
85-
from PowerPlatform.Dataverse.models.filters import eq, gt
89+
from PowerPlatform.Dataverse.models.filters import col
8690
8791
for record in (client.query.builder("account")
88-
.where((eq("statecode", 0) | eq("statecode", 1))
89-
& gt("revenue", 100000))
92+
.where((col("statecode") == 0) | (col("statecode") == 1))
93+
.where(col("revenue") > 100_000)
9094
.execute()):
9195
print(record["name"])
9296
"""

tests/unit/test_phase2_ga.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,105 @@ def test_list_conversion(self):
165165
self.assertEqual(list(qr), recs)
166166

167167

168+
class TestQueryResultListLikeContract(unittest.TestCase):
169+
"""Contract tests: QueryResult must be substitutable for ``list[Record]``
170+
in the patterns shown in the class docstring, examples, and skill docs.
171+
172+
These tests exist to catch the gap from PR #175 where ``__getitem__`` was
173+
missing despite docs and examples treating ``QueryResult`` as list-like.
174+
They drive the contract from the *caller's* perspective rather than
175+
introspecting which dunders are implemented, so removing any single dunder
176+
breaks at least one assertion here with a clear signal.
177+
"""
178+
179+
def _records(self, n=3):
180+
return [Record(id=f"id-{i}", table="account", data={"name": f"R{i}"}) for i in range(n)]
181+
182+
def test_contract_index_then_field_access(self):
183+
"""Pattern from examples/advanced/fetchxml.py: ``row = result[0]; row.get(...)``."""
184+
qr = QueryResult(self._records(3))
185+
row = qr[0]
186+
self.assertEqual(row.get("name"), "R0")
187+
188+
def test_contract_single_loop_field_access(self):
189+
"""Pattern from examples/basic/installation_example.py: ``for r in result: r["name"]``."""
190+
qr = QueryResult(self._records(3))
191+
names = [r["name"] for r in qr]
192+
self.assertEqual(names, ["R0", "R1", "R2"])
193+
194+
def test_contract_first_with_none_guard(self):
195+
"""Pattern recommended by Copilot review: ``result.first()`` with None-check."""
196+
empty = QueryResult([])
197+
nonempty = QueryResult(self._records(2))
198+
self.assertIsNone(empty.first())
199+
first = nonempty.first()
200+
self.assertIsNotNone(first)
201+
self.assertEqual(first.get("name"), "R0") # type: ignore[union-attr]
202+
203+
def test_contract_truthy_guard(self):
204+
"""Pattern: ``if result: ...`` to skip empty results before indexing."""
205+
if QueryResult(self._records(1)):
206+
ok = True
207+
else:
208+
ok = False
209+
self.assertTrue(ok)
210+
self.assertFalse(bool(QueryResult([])))
211+
212+
def test_contract_len_for_size_check(self):
213+
"""Pattern: ``f"{len(result)} rows"`` in log/print statements."""
214+
self.assertEqual(len(QueryResult(self._records(7))), 7)
215+
self.assertEqual(len(QueryResult([])), 0)
216+
217+
def test_contract_slice_returns_list_like(self):
218+
"""Slicing must yield something that supports iteration and len()."""
219+
qr = QueryResult(self._records(5))
220+
page = qr[1:4]
221+
self.assertEqual(len(page), 3)
222+
self.assertEqual([r.get("name") for r in page], ["R1", "R2", "R3"])
223+
224+
def test_contract_negative_index(self):
225+
"""``result[-1]`` for "last record" is a common Python idiom."""
226+
qr = QueryResult(self._records(3))
227+
self.assertEqual(qr[-1].get("name"), "R2")
228+
229+
def test_contract_list_conversion_round_trip(self):
230+
"""``list(result)`` must yield the same records iteration yields."""
231+
recs = self._records(4)
232+
qr = QueryResult(recs)
233+
self.assertEqual(list(qr), recs)
234+
self.assertEqual(list(qr), [r for r in qr])
235+
236+
def test_contract_iteration_does_not_consume(self):
237+
"""Multiple ``for`` loops over the same result must all see records.
238+
239+
Guards against an accidental refactor to a single-shot iterator.
240+
"""
241+
qr = QueryResult(self._records(3))
242+
first_pass = list(qr)
243+
second_pass = list(qr)
244+
self.assertEqual(first_pass, second_pass)
245+
self.assertEqual(len(first_pass), 3)
246+
247+
def test_contract_nested_loop_iterates_records_not_fields(self):
248+
"""Regression guard for the bug in installation_example.py (PR #175 review #3).
249+
250+
``for page in result: for r in page: ...`` would iterate Record keys
251+
if QueryResult were a flat iterable of Records (each Record is also
252+
iterable over its keys). This test makes it explicit that the outer
253+
loop yields Records, not pages — so callers know to use a single loop.
254+
"""
255+
qr = QueryResult(self._records(2))
256+
outer = list(qr)
257+
self.assertTrue(all(isinstance(r, Record) for r in outer))
258+
259+
def test_contract_records_attribute_is_underlying_list(self):
260+
"""``result.records`` is the documented escape hatch for list-only APIs."""
261+
recs = self._records(3)
262+
qr = QueryResult(recs)
263+
self.assertIsInstance(qr.records, list)
264+
self.assertIs(qr.records, recs)
265+
266+
168267
class TestExecuteReturnsQueryResult(unittest.TestCase):
169268
"""execute() flat mode returns QueryResult."""
170269

0 commit comments

Comments
 (0)