@@ -791,12 +791,6 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
791791 yield [x for x in items if isinstance (x , dict )]
792792 next_link = data .get ("@odata.nextLink" ) or data .get ("odata.nextLink" ) if isinstance (data , dict ) else None
793793
794- # ----------------------- SELECT * detection -----------------------
795- _SELECT_STAR_RE = re .compile (
796- r"\bSELECT\b(\s+(?:DISTINCT\s+)?(?:TOP\s+\d+(?:\s+PERCENT)?\s+)?)\*\s" ,
797- re .IGNORECASE ,
798- )
799-
800794 # ----------------------- SQL guardrail patterns --------------------
801795 _SQL_WRITE_RE = re .compile (
802796 r"^\s*(?:INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|EXEC|GRANT|REVOKE|BULK)\b" ,
@@ -808,7 +802,6 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
808802 r"\bFROM\s+[A-Za-z0-9_]+(?:\s+[A-Za-z0-9_]+)?\s*,\s*[A-Za-z0-9_]+" ,
809803 re .IGNORECASE ,
810804 )
811- _SQL_HAS_JOIN_RE = re .compile (r"\bJOIN\b" , re .IGNORECASE )
812805 # Server-blocked SQL patterns (save the round-trip by catching early)
813806 _SQL_UNSUPPORTED_JOIN_RE = re .compile (
814807 r"\b(?:CROSS\s+JOIN|RIGHT\s+(?:OUTER\s+)?JOIN|FULL\s+(?:OUTER\s+)?JOIN)\b" ,
@@ -821,44 +814,14 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
821814 r"\bIN\s*\(\s*SELECT\b|\bEXISTS\s*\(\s*SELECT\b|\(\s*SELECT\b.*\bFROM\b" ,
822815 re .IGNORECASE ,
823816 )
824-
825- def _expand_select_star (self , sql : str , table : str ) -> str :
826- """Replace ``SELECT *`` with explicit column names.
827-
828- When the Dataverse SQL endpoint receives ``SELECT *`` it returns
829- an error ("SELECT * is not supported"). This helper resolves all
830- columns via ``_list_columns`` and rewrites the query so the user
831- never has to know the server limitation.
832-
833- For JOIN queries, the expansion only includes columns from the first
834- (FROM) table. A warning is emitted so the user knows to specify
835- columns explicitly for multi-table queries.
836- """
837- if not self ._SELECT_STAR_RE .search (sql ):
838- return sql
839-
840- # Warn on SELECT * with JOINs -- expansion uses only the FROM table
841- if self ._SQL_HAS_JOIN_RE .search (sql ):
842- warnings .warn (
843- "SELECT * with JOIN: the SDK expands * using columns from "
844- "the first table only. Columns from joined tables will not "
845- "be included. Specify columns explicitly for JOINs "
846- "(e.g. SELECT a.name, c.fullname FROM account a "
847- "JOIN contact c ON ...)." ,
848- UserWarning ,
849- stacklevel = 4 ,
850- )
851-
852- cols = self ._list_columns (
853- table ,
854- select = ["LogicalName" ],
855- filter = "AttributeType ne 'Virtual'" ,
856- )
857- col_names = sorted ({c ["LogicalName" ] for c in cols if "LogicalName" in c })
858- if not col_names :
859- return sql # Fallback: let the server decide
860- col_list = ", " .join (col_names )
861- return self ._SELECT_STAR_RE .sub (lambda m : f"SELECT{ m .group (1 )} { col_list } " , sql , count = 1 )
817+ # SELECT * is intentionally rejected -- not a technical limitation but a
818+ # deliberate design decision. Wide entities (e.g. account has 307 columns)
819+ # make SELECT * extremely expensive on shared database infrastructure.
820+ # COUNT(*) is NOT matched because COUNT appears before the *.
821+ _SQL_SELECT_STAR_RE = re .compile (
822+ r"\bSELECT\b\s+(?:DISTINCT\s+)?(?:TOP\s+\d+(?:\s+PERCENT)?\s+)?\*\s" ,
823+ re .IGNORECASE ,
824+ )
862825
863826 def _sql_guardrails (self , sql : str ) -> str :
864827 """Apply safety guardrails to a SQL query before sending to the server.
@@ -873,19 +836,22 @@ def _sql_guardrails(self, sql: str) -> str:
873836 4. HAVING clause (server rejects)
874837 5. CTE / WITH clause (server rejects)
875838 6. Subqueries -- IN (SELECT ...), EXISTS (SELECT ...) (server rejects)
839+ 7. SELECT * -- intentional design decision, not a technical limitation.
840+ Wide entities make wildcard selects extremely expensive on shared
841+ database infrastructure. ``COUNT(*)`` is not affected.
876842
877843 **Warned** (``UserWarning`` -- query still executes):
878844
879- 7 . Leading-wildcard LIKE (full table scan)
880- 8 . Implicit cross join FROM a, b (cartesian product)
845+ 8 . Leading-wildcard LIKE (full table scan)
846+ 9 . Implicit cross join FROM a, b (cartesian product)
881847
882848 All blocked patterns are also blocked by the server, but catching
883849 them here saves the network round-trip and provides clearer error
884850 messages. To bypass a specific check (e.g., if the server adds
885851 support in the future), all checks are in this single method.
886852
887853 :param sql: The SQL string (already stripped).
888- :return: The SQL string (unchanged unless rewritten ).
854+ :return: The SQL string (unchanged).
889855 :raises ValidationError: If the SQL contains a blocked pattern.
890856 """
891857 # --- BLOCKED (save server round-trip) ---
@@ -944,9 +910,22 @@ def _sql_guardrails(self, sql: str) -> str:
944910 subcode = VALIDATION_SQL_UNSUPPORTED_SYNTAX ,
945911 )
946912
913+ # 7. Block SELECT * -- intentional design decision.
914+ # Wide entities (e.g. account has 307 columns) make wildcard selects
915+ # extremely expensive on shared database infrastructure.
916+ # COUNT(*) is NOT matched: _SQL_SELECT_STAR_RE requires * to be the
917+ # first token after SELECT/DISTINCT/TOP N, so COUNT appears before *.
918+ if self ._SQL_SELECT_STAR_RE .search (sql ):
919+ raise ValidationError (
920+ "SELECT * is not supported. Specify column names explicitly "
921+ "(e.g. SELECT name, revenue FROM account). "
922+ "Use client.query.sql_columns('account') to discover available columns." ,
923+ subcode = VALIDATION_SQL_UNSUPPORTED_SYNTAX ,
924+ )
925+
947926 # --- WARNED (query still executes) ---
948927
949- # 7 . Warn on leading-wildcard LIKE
928+ # 8 . Warn on leading-wildcard LIKE
950929 if self ._SQL_LEADING_WILDCARD_RE .search (sql ):
951930 warnings .warn (
952931 "Query contains a leading-wildcard LIKE pattern "
@@ -957,7 +936,7 @@ def _sql_guardrails(self, sql: str) -> str:
957936 stacklevel = 4 ,
958937 )
959938
960- # 8 . Warn on implicit cross joins (server allows but risky)
939+ # 9 . Warn on implicit cross joins (server allows but risky)
961940 if self ._SQL_IMPLICIT_CROSS_JOIN_RE .search (sql ):
962941 warnings .warn (
963942 "Query uses an implicit cross join (FROM table1, table2). "
@@ -987,8 +966,9 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
987966 .. note::
988967 Endpoint form: ``GET /{entity_set}?sql=<encoded select>``. The client
989968 extracts the logical table name, resolves the entity set (metadata
990- cached), then issues the request. ``SELECT *`` is automatically
991- expanded into explicit column names because the server blocks it.
969+ cached), then issues the request. ``SELECT *`` raises
970+ :class:`~PowerPlatform.Dataverse.core.errors.ValidationError` --
971+ it is deliberately rejected, not silently rewritten.
992972 """
993973 if not isinstance (sql , str ):
994974 raise ValidationError ("sql must be a string" , subcode = VALIDATION_SQL_NOT_STRING )
@@ -1008,13 +988,8 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
1008988 subcode = VALIDATION_SQL_WRITE_BLOCKED ,
1009989 )
1010990
1011- # Extract logical table name via helper (robust to identifiers ending with 'from')
1012- logical = self ._extract_logical_table (sql )
1013-
1014- # Auto-expand SELECT * into explicit column names
1015- sql = self ._expand_select_star (sql , logical )
1016-
1017- # Apply safety guardrails (block unsupported syntax, warn on risky patterns)
991+ # Apply safety guardrails (block unsupported syntax, warn on risky patterns).
992+ # SELECT * raises ValidationError here before any table resolution.
1018993 sql = self ._sql_guardrails (sql )
1019994
1020995 r = self ._execute_raw (self ._build_sql (sql ))
0 commit comments