4646
4747from __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
5151import pandas as pd
5252
5353from . import filters
5454from .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
5977class 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" ],
0 commit comments