Skip to content

Commit b8a4db6

Browse files
author
Abel Milash
committed
Address PR review: add QueryParams TypedDict, unbounded query guard, filter_raw warning
1 parent 5c2a17c commit b8a4db6

3 files changed

Lines changed: 132 additions & 47 deletions

File tree

src/PowerPlatform/Dataverse/models/query_builder.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,32 @@
4646

4747
from __future__ import annotations
4848

49-
from typing import Any, Collection, Dict, Iterable, List, Optional, Sequence, Union
49+
from typing import Any, Collection, Dict, Iterable, List, Optional, Sequence, TypedDict, Union
5050

5151
import pandas as pd
5252

5353
from . import filters
5454
from .record import Record
5555

56-
__all__ = ["QueryBuilder", "ExpandOption"]
56+
__all__ = ["QueryBuilder", "QueryParams", "ExpandOption"]
57+
58+
59+
class QueryParams(TypedDict, total=False):
60+
"""Typed dictionary returned by :meth:`QueryBuilder.build`.
61+
62+
Provides IDE autocomplete when passing build results to
63+
``client.records.get()`` manually.
64+
"""
65+
66+
table: str
67+
select: List[str]
68+
filter: str
69+
orderby: List[str]
70+
expand: List[str]
71+
top: int
72+
page_size: int
73+
count: bool
74+
include_annotations: str
5775

5876

5977
class ExpandOption:
@@ -390,6 +408,11 @@ def filter_raw(self, filter_string: str) -> QueryBuilder:
390408
Use this for complex filters not covered by other methods.
391409
Column names in the filter string should be lowercase.
392410
411+
.. warning::
412+
The filter string is passed directly to Dataverse without validation.
413+
Ensure it follows OData filter syntax; a malformed expression will result
414+
in a ``400 Bad Request`` error from the server.
415+
393416
:param filter_string: Raw OData filter expression.
394417
:return: Self for method chaining.
395418
@@ -585,19 +608,19 @@ def expand(self, *relations: Union[str, ExpandOption]) -> QueryBuilder:
585608

586609
# --------------------------------------------------------------- build
587610

588-
def build(self) -> dict:
611+
def build(self) -> QueryParams:
589612
"""Build query parameters dictionary.
590613
591-
Returns a dictionary suitable for passing to the OData layer.
592-
All ``filter_*()`` and ``where()`` clauses are AND-joined into
593-
a single ``filter`` string in call order.
614+
Returns a :class:`QueryParams` dictionary suitable for passing to
615+
the OData layer. All ``filter_*()`` and ``where()`` clauses are
616+
AND-joined into a single ``filter`` string in call order.
594617
595618
:return: Dictionary with ``table`` and optionally ``select``,
596619
``filter``, ``orderby``, ``expand``, ``top``, ``page_size``,
597620
``count``, ``include_annotations``.
598-
:rtype: dict
621+
:rtype: QueryParams
599622
"""
600-
params: dict = {"table": self.table}
623+
params: QueryParams = {"table": self.table}
601624
if self._select:
602625
params["select"] = list(self._select)
603626
if self._filter_parts:
@@ -622,6 +645,23 @@ def build(self) -> dict:
622645
params["include_annotations"] = self._include_annotations
623646
return params
624647

648+
# --------------------------------------------------------------- guards
649+
650+
def _validate_constraints(self) -> None:
651+
"""Raise if the query has no limiting constraints.
652+
653+
At least one of ``select``, ``filter``, or ``top`` must be set
654+
before executing a query to prevent accidental full-table scans.
655+
656+
:raises ValueError: If none of ``select()``, ``filter_*()``,
657+
``where()``, or ``top()`` has been called.
658+
"""
659+
if not (self._select or self._filter_parts or self._top is not None):
660+
raise ValueError(
661+
"Unbounded query: set at least one of select(), filter_*(), "
662+
"where(), or top() before calling execute() or to_dataframe()."
663+
)
664+
625665
# --------------------------------------------------------------- execute
626666

627667
def execute(self, *, by_page: bool = False) -> Union[Iterable[Record], Iterable[List[Record]]]:
@@ -636,6 +676,11 @@ def execute(self, *, by_page: bool = False) -> Union[Iterable[Record], Iterable[
636676
instances should use :meth:`build` to get parameters and pass them
637677
to ``client.records.get()`` manually.
638678
679+
At least one of ``select()``, ``filter_*()``, ``where()``, or
680+
``top()`` must be called before ``execute()``; otherwise a
681+
:class:`ValueError` is raised to prevent accidental full-table
682+
scans.
683+
639684
:param by_page: If ``True``, yield pages (lists of
640685
:class:`~PowerPlatform.Dataverse.models.record.Record` objects)
641686
instead of individual records. Defaults to ``False``.
@@ -644,6 +689,8 @@ def execute(self, *, by_page: bool = False) -> Union[Iterable[Record], Iterable[
644689
:class:`~PowerPlatform.Dataverse.models.record.Record` objects
645690
(default) or pages of records (when ``by_page=True``).
646691
:rtype: Iterable[Record] or Iterable[List[Record]]
692+
:raises ValueError: If no ``select``, ``filter``, or ``top``
693+
constraint has been set.
647694
:raises RuntimeError: If the query was not created via
648695
``client.query.builder()``.
649696
@@ -668,6 +715,7 @@ def execute(self, *, by_page: bool = False) -> Union[Iterable[Record], Iterable[
668715
"Cannot execute: query was not created via client.query.builder(). "
669716
"Use build() and pass parameters to client.records.get() instead."
670717
)
718+
self._validate_constraints()
671719
params = self.build()
672720
client = self._query_ops._client
673721

@@ -703,9 +751,16 @@ def to_dataframe(self) -> pd.DataFrame:
703751
This method is only available when the QueryBuilder was created
704752
via ``client.query.builder(table)``.
705753
754+
At least one of ``select()``, ``filter_*()``, ``where()``, or
755+
``top()`` must be called before ``to_dataframe()``; otherwise a
756+
:class:`ValueError` is raised to prevent accidental full-table
757+
scans.
758+
706759
:return: DataFrame containing all matching records. Returns an empty
707760
DataFrame when no records match.
708761
:rtype: ~pandas.DataFrame
762+
:raises ValueError: If no ``select``, ``filter``, or ``top``
763+
constraint has been set.
709764
:raises RuntimeError: If the query was not created via
710765
``client.query.builder()``.
711766
@@ -722,6 +777,7 @@ def to_dataframe(self) -> pd.DataFrame:
722777
"Cannot execute: query was not created via client.query.builder(). "
723778
"Use build() and pass parameters to client.dataframe.get() instead."
724779
)
780+
self._validate_constraints()
725781
params = self.build()
726782
return self._query_ops._client.dataframe.get(
727783
params["table"],

tests/unit/models/test_query_builder.py

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ def test_execute_returns_flat_records_by_default(self):
733733

734734
qb = QueryBuilder("account")
735735
qb._query_ops = mock_query_ops
736+
qb.select("name")
736737
records = list(qb.execute())
737738

738739
self.assertEqual(len(records), 3)
@@ -750,32 +751,75 @@ def test_execute_by_page_returns_pages(self):
750751

751752
qb = QueryBuilder("account")
752753
qb._query_ops = mock_query_ops
754+
qb.select("name")
753755
pages = list(qb.execute(by_page=True))
754756

755757
self.assertEqual(len(pages), 2)
756758
self.assertEqual(pages[0], page1)
757759
self.assertEqual(pages[1], page2)
758760

759-
def test_execute_passes_none_for_empty_options(self):
761+
def test_execute_unbounded_raises(self):
762+
"""execute() with no select/filter/top should raise ValueError."""
763+
mock_query_ops = MagicMock()
764+
qb = QueryBuilder("account")
765+
qb._query_ops = mock_query_ops
766+
with self.assertRaises(ValueError) as ctx:
767+
qb.execute()
768+
self.assertIn("Unbounded query", str(ctx.exception))
769+
770+
def test_execute_with_only_select_succeeds(self):
771+
"""execute() with select only should not raise."""
760772
mock_query_ops = MagicMock()
761773
mock_client = mock_query_ops._client
762774
mock_client.records.get.return_value = iter([])
763775

764776
qb = QueryBuilder("account")
765777
qb._query_ops = mock_query_ops
766-
list(qb.execute())
778+
qb.select("name")
779+
list(qb.execute()) # should not raise
780+
mock_client.records.get.assert_called_once()
767781

768-
mock_client.records.get.assert_called_once_with(
769-
"account",
770-
select=None,
771-
filter=None,
772-
orderby=None,
773-
top=None,
774-
expand=None,
775-
page_size=None,
776-
count=False,
777-
include_annotations=None,
778-
)
782+
def test_execute_with_only_filter_succeeds(self):
783+
"""execute() with filter only should not raise."""
784+
mock_query_ops = MagicMock()
785+
mock_client = mock_query_ops._client
786+
mock_client.records.get.return_value = iter([])
787+
788+
qb = QueryBuilder("account")
789+
qb._query_ops = mock_query_ops
790+
qb.filter_eq("statecode", 0)
791+
list(qb.execute()) # should not raise
792+
mock_client.records.get.assert_called_once()
793+
794+
def test_execute_with_only_top_succeeds(self):
795+
"""execute() with top only should not raise."""
796+
mock_query_ops = MagicMock()
797+
mock_client = mock_query_ops._client
798+
mock_client.records.get.return_value = iter([])
799+
800+
qb = QueryBuilder("account")
801+
qb._query_ops = mock_query_ops
802+
qb.top(10)
803+
list(qb.execute()) # should not raise
804+
mock_client.records.get.assert_called_once()
805+
806+
def test_execute_with_only_expand_raises(self):
807+
"""expand alone is not a sufficient constraint."""
808+
mock_query_ops = MagicMock()
809+
qb = QueryBuilder("account")
810+
qb._query_ops = mock_query_ops
811+
qb.expand("primarycontactid")
812+
with self.assertRaises(ValueError):
813+
qb.execute()
814+
815+
def test_execute_with_only_count_raises(self):
816+
"""count alone is not a sufficient constraint."""
817+
mock_query_ops = MagicMock()
818+
qb = QueryBuilder("account")
819+
qb._query_ops = mock_query_ops
820+
qb.count()
821+
with self.assertRaises(ValueError):
822+
qb.execute()
779823

780824
def test_execute_with_where_expressions(self):
781825
from PowerPlatform.Dataverse.models.filters import eq, gt
@@ -819,12 +863,12 @@ def test_execute_passes_count_and_annotations(self):
819863

820864
qb = QueryBuilder("account")
821865
qb._query_ops = mock_query_ops
822-
qb.count().include_formatted_values()
866+
qb.select("name").count().include_formatted_values()
823867
list(qb.execute())
824868

825869
mock_client.records.get.assert_called_once_with(
826870
"account",
827-
select=None,
871+
select=["name"],
828872
filter=None,
829873
orderby=None,
830874
top=None,
@@ -874,29 +918,14 @@ def test_to_dataframe_delegates_to_dataframe_get(self):
874918
)
875919
pd.testing.assert_frame_equal(result, expected_df)
876920

877-
def test_to_dataframe_passes_none_for_empty_options(self):
878-
"""to_dataframe() with no options should pass None for all optional params."""
879-
import pandas as pd
880-
921+
def test_to_dataframe_unbounded_raises(self):
922+
"""to_dataframe() with no select/filter/top should raise ValueError."""
881923
mock_query_ops = MagicMock()
882-
mock_client = mock_query_ops._client
883-
mock_client.dataframe.get.return_value = pd.DataFrame()
884-
885924
qb = QueryBuilder("account")
886925
qb._query_ops = mock_query_ops
887-
qb.to_dataframe()
888-
889-
mock_client.dataframe.get.assert_called_once_with(
890-
"account",
891-
select=None,
892-
filter=None,
893-
orderby=None,
894-
top=None,
895-
expand=None,
896-
page_size=None,
897-
count=False,
898-
include_annotations=None,
899-
)
926+
with self.assertRaises(ValueError) as ctx:
927+
qb.to_dataframe()
928+
self.assertIn("Unbounded query", str(ctx.exception))
900929

901930
def test_to_dataframe_returns_dataframe(self):
902931
"""to_dataframe() should return a pandas DataFrame."""
@@ -928,12 +957,12 @@ def test_to_dataframe_forwards_count_and_annotations(self):
928957

929958
qb = QueryBuilder("account")
930959
qb._query_ops = mock_query_ops
931-
qb.count().include_formatted_values()
960+
qb.select("name").count().include_formatted_values()
932961
qb.to_dataframe()
933962

934963
mock_client.dataframe.get.assert_called_once_with(
935964
"account",
936-
select=None,
965+
select=["name"],
937966
filter=None,
938967
orderby=None,
939968
top=None,

tests/unit/test_query_operations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def test_builder_execute_flat_multiple_pages(self):
9191
"""execute() should flatten records from multiple pages."""
9292
self.client._odata._get_multiple.return_value = iter([[{"accountid": "1"}], [{"accountid": "2"}]])
9393

94-
records = list(self.client.query.builder("account").execute())
94+
records = list(self.client.query.builder("account").select("name").execute())
9595

9696
self.assertEqual(len(records), 2)
9797
self.assertEqual(records[0]["accountid"], "1")
@@ -101,7 +101,7 @@ def test_builder_execute_by_page(self):
101101
"""execute(by_page=True) should yield pages."""
102102
self.client._odata._get_multiple.return_value = iter([[{"accountid": "1"}], [{"accountid": "2"}]])
103103

104-
pages = list(self.client.query.builder("account").execute(by_page=True))
104+
pages = list(self.client.query.builder("account").select("name").execute(by_page=True))
105105

106106
self.assertEqual(len(pages), 2)
107107
self.assertEqual(len(pages[0]), 1)

0 commit comments

Comments
 (0)