Skip to content

Commit 94adc28

Browse files
committed
Use platform ?sql param for SQL SELECT
1 parent e93fa6e commit 94adc28

7 files changed

Lines changed: 119 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Foundry–style apps.
44

5-
- Read (SQL) — Execute read-only T‑SQL via the McpExecuteSqlQuery Custom API. Returns `list[dict]`.
5+
- Read (SQL) — Execute constrained read-only SQL via the Dataverse Web API `?sql=` parameter. Returns `list[dict]`.
66
- OData CRUD — Thin wrappers over Dataverse Web API (create/get/update/delete).
77
- Bulk create — Pass a list of records to `create(...)` to invoke the bound `CreateMultiple` action; returns `list[str]` of GUIDs. If `@odata.type` is absent the SDK resolves the logical name from metadata (cached).
88
- Retrieve multiple (paging) — Generator-based `get_multiple(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`).
@@ -13,7 +13,7 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
1313
## Features
1414

1515
- Simple `DataverseClient` facade for CRUD, SQL (read-only), and table metadata.
16-
- SQL-over-API: T-SQL routed through Custom API endpoint (no ODBC / TDS driver required).
16+
- SQL-over-API: Constrained T-SQL (single SELECT with limited WHERE/TOP/ORDER BY) via native Web API `?sql=` parameter.
1717
- Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them.
1818
- Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(entity_set, payloads)`; returns list of created IDs.
1919
- Retrieve multiple with server-driven paging: `get_multiple(...)` yields lists (pages) following `@odata.nextLink`. Control total via `$top` and per-page via `page_size` (Prefer: `odata.maxpagesize`).
@@ -33,12 +33,12 @@ Create and activate a Python 3.13+ environment, then install dependencies:
3333
python -m pip install -r requirements.txt
3434
```
3535

36-
Direct TDS via ODBC is not used; SQL reads are executed via the Custom API over OData.
36+
Direct TDS via ODBC is not used; SQL reads are executed via the Web API using the `?sql=` query parameter.
3737

3838
## Configuration Notes
3939

4040
- For Web API (OData), tokens target your Dataverse org URL scope: https://yourorg.crm.dynamics.com/.default. The SDK requests this scope from the provided TokenCredential.
41-
- For complete functionalities, please use one of the PREPROD BAP environments, otherwise McpExecuteSqlQuery might not work.
41+
(Preprod environments may surface newest SQL subset capabilities sooner than production.)
4242

4343
### Configuration (DataverseConfig)
4444

@@ -48,7 +48,7 @@ Pass a `DataverseConfig` or rely on sane defaults:
4848
from dataverse_sdk import DataverseClient
4949
from dataverse_sdk.config import DataverseConfig
5050

51-
cfg = DataverseConfig() # defaults: language_code=1033, sql_api_name="McpExecuteSqlQuery"
51+
cfg = DataverseConfig() # defaults: language_code=1033
5252
client = DataverseClient(base_url="https://yourorg.crm.dynamics.com", config=cfg)
5353

5454
# Optional HTTP tunables (timeouts/retries)
@@ -68,7 +68,7 @@ The quickstart demonstrates:
6868
- Creating, reading, updating, and deleting records (OData)
6969
- Bulk create (CreateMultiple) to insert many records in one call
7070
- Retrieve multiple with paging (contrasting `$top` vs `page_size`)
71-
- Executing a read-only SQL query
71+
- Executing a read-only SQL query (Web API `?sql=`)
7272

7373
## Examples
7474

@@ -103,7 +103,7 @@ updated = client.update("accounts", account_id, {"telephone1": "555-0199"})
103103
# Delete
104104
client.delete("accounts", account_id)
105105

106-
# SQL (read-only) via Custom API
106+
# SQL (read-only) via Web API `?sql=`
107107
rows = client.query_sql("SELECT TOP 3 accountid, name FROM account ORDER BY createdon DESC")
108108
for r in rows:
109109
print(r.get("accountid"), r.get("name"))
@@ -239,7 +239,7 @@ Notes:
239239
- Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs.
240240
- Use `get_multiple` for paging through result sets; prefer `select` to limit columns.
241241
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.
242-
- SQL is routed through the Custom API named in `DataverseConfig.sql_api_name` (default: `McpExecuteSqlQuery`).
242+
* SQL queries are executed directly against entity set endpoints using the `?sql=` parameter. Supported subset only (single SELECT, optional WHERE/TOP/ORDER BY, alias). Unsupported constructs will be rejected by the service.
243243

244244
### Pandas helpers
245245

‎examples/quickstart.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -286,15 +286,17 @@ def print_line_summaries(label: str, summaries: list[dict]) -> None:
286286
except Exception as e:
287287
print(f"Update/verify failed: {e}")
288288
sys.exit(1)
289-
# 4) Query records via SQL Custom API
290-
print("Query (SQL via Custom API):")
289+
# 4) Query records via SQL (Web API ?sql=)
290+
print("Query (SQL via Web API ?sql=):")
291291
try:
292292
import time
293293
pause("Execute SQL Query")
294294

295295
def _run_query():
296-
log_call(f"client.query_sql(\"SELECT TOP 2 * FROM {logical} ORDER BY {attr_prefix}_amount DESC\")")
297-
return client.query_sql(f"SELECT TOP 2 * FROM {logical} ORDER BY {attr_prefix}_amount DESC")
296+
cols = f"{id_key}, {code_key}, {amount_key}, {when_key}"
297+
query = f"SELECT TOP 2 {cols} FROM {logical} ORDER BY {attr_prefix}_amount DESC"
298+
log_call(f"client.query_sql(\"{query}\") (Web API ?sql=)")
299+
return client.query_sql(query)
298300

299301
def _retry_if(ex: Exception) -> bool:
300302
msg = str(ex) if ex else ""
@@ -317,7 +319,7 @@ def _retry_if(ex: Exception) -> bool:
317319
)
318320
print_line_summaries("TDS record summaries (top 2 by amount):", tds_summaries)
319321
except Exception as e:
320-
print(f"SQL via Custom API failed: {e}")
322+
print(f"SQL query failed: {e}")
321323

322324
# Pause between SQL query and retrieve-multiple demos
323325
pause("Retrieve multiple (OData paging demos)")

‎examples/quickstart_pandas.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
183183
print(f"Update/verify failed: {e}")
184184
sys.exit(1)
185185

186-
# 4) Query records via SQL Custom API
187-
print("(Pandas) Query (SQL via Custom API):")
186+
# 4) Query records via SQL (Web API ?sql=)
187+
print("(Pandas) Query (SQL via Web API ?sql=):")
188188
try:
189189
# Try singular logical name first, then plural entity set, with short backoff
190190
import time
@@ -196,7 +196,9 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
196196
df_rows = None
197197
for name in candidates:
198198
def _run_query():
199-
return PANDAS.query_sql_df(f"SELECT TOP 3 * FROM {name} ORDER BY createdon DESC")
199+
id_key = f"{logical}id"
200+
cols = f"{id_key}, {attr_prefix}_code, {attr_prefix}_amount, {attr_prefix}_when"
201+
return PANDAS.query_sql_df(f"SELECT TOP 3 {cols} FROM {name} ORDER BY {attr_prefix}_amount DESC")
200202
def _retry_if(ex: Exception) -> bool:
201203
msg = str(ex) if ex else ""
202204
return ("Invalid table name" in msg) or ("Invalid object name" in msg)
@@ -211,7 +213,7 @@ def _retry_if(ex: Exception) -> bool:
211213
except SystemExit:
212214
pass
213215
except Exception as e:
214-
print(f"SQL via Custom API failed: {e}")
216+
print(f"SQL query failed: {e}")
215217

216218
# 5) Delete record
217219
print("(Pandas) Delete (OData via Pandas wrapper):")

‎src/dataverse_sdk/client.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,19 +164,23 @@ def get_multiple(
164164
page_size=page_size,
165165
)
166166

167-
# SQL via Custom API
167+
# SQL via Web API sql parameter
168168
def query_sql(self, tsql: str):
169-
"""Execute a read-only SQL query via the configured Custom API.
169+
"""Execute a read-only SQL query using the Dataverse Web API `?sql=` capability.
170+
171+
The query must follow the currently supported subset: single SELECT with optional WHERE,
172+
TOP (integer), ORDER BY (columns only), and simple alias after FROM. Example:
173+
``SELECT TOP 3 accountid, name FROM account ORDER BY name DESC``
170174
171175
Parameters
172176
----------
173177
tsql : str
174-
A SELECT-only T-SQL statement (e.g., ``"SELECT TOP 3 * FROM account"``).
178+
Supported single SELECT statement.
175179
176180
Returns
177181
-------
178182
list[dict]
179-
Rows as a list of dictionaries.
183+
Result rows (empty list if none).
180184
"""
181185
return self._get_odata().query_sql(tsql)
182186

‎src/dataverse_sdk/config.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
@dataclass(frozen=True)
88
class DataverseConfig:
99
language_code: int = 1033
10-
sql_api_name: str = "McpExecuteSqlQuery"
1110

1211
# Optional HTTP tuning (not yet wired everywhere; reserved for future use)
1312
http_retries: Optional[int] = None
@@ -19,7 +18,6 @@ def from_env(cls) -> "DataverseConfig":
1918
# Environment-free defaults
2019
return cls(
2120
language_code=1033,
22-
sql_api_name="McpExecuteSqlQuery",
2321
http_retries=None,
2422
http_backoff=None,
2523
http_timeout=None,

‎src/dataverse_sdk/odata.py‎

Lines changed: 87 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def __init__(self, auth, base_url: str, config=None) -> None:
2929
)
3030
# Cache: entity set name -> logical name (resolved via metadata lookup)
3131
self._entityset_logical_cache = {}
32+
# Cache: logical name -> entity set name (reverse lookup for SQL endpoint)
33+
self._logical_to_entityset_cache: dict[str, str] = {}
3234

3335
def _headers(self) -> Dict[str, str]:
3436
"""Build standard OData headers with bearer auth."""
@@ -318,41 +320,105 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
318320

319321
# --------------------------- SQL Custom API -------------------------
320322
def query_sql(self, tsql: str) -> list[dict[str, Any]]:
321-
"""Execute a read-only T-SQL query via the configured Custom API.
323+
"""Execute a read-only SQL query using the Dataverse Web API `?sql=` capability.
324+
325+
The platform supports a constrained subset of SQL SELECT statements directly on entity set endpoints:
326+
GET /{entity_set}?sql=<encoded select statement>
327+
328+
This client extracts the logical table name from the query, resolves the corresponding
329+
entity set name (cached) and invokes the Web API using the `sql` query parameter.
322330
323331
Parameters
324332
----------
325333
tsql : str
326-
SELECT-style Dataverse-supported T-SQL (read-only).
334+
Single SELECT statement within supported subset.
327335
328336
Returns
329337
-------
330338
list[dict]
331-
Rows materialised as list of dictionaries (empty list if no rows).
339+
Result rows (empty list if none).
332340
333341
Raises
334342
------
343+
ValueError
344+
If the SQL is empty or malformed, or if the table logical name cannot be determined.
335345
RuntimeError
336-
If the Custom API response is missing the expected ``queryresult`` property or type is unexpected.
346+
If metadata lookup for the logical name fails.
337347
"""
338-
payload = {"querytext": tsql}
339-
headers = self._headers()
340-
api_name = self.config.sql_api_name
341-
url = f"{self.api}/{api_name}"
342-
r = self._request("post", url, headers=headers, json=payload)
348+
if not isinstance(tsql, str) or not tsql.strip():
349+
raise ValueError("tsql must be a non-empty string")
350+
sql = tsql.strip()
351+
352+
# Naive parse: find token after FROM (ignore brackets); stop at whitespace/newline
353+
# Example: SELECT name FROM account AS a WHERE a.name LIKE 'Acme%'
354+
m = re.search(r"from\s+([a-zA-Z0-9_]+)\b", sql, flags=re.IGNORECASE)
355+
if not m:
356+
raise ValueError("Unable to determine table logical name from SQL (expected 'FROM <logical>').")
357+
logical_candidate = m.group(1)
358+
logical = logical_candidate.lower()
359+
360+
entity_set = self._entity_set_from_logical(logical)
361+
# Issue GET /{entity_set}?sql=<query>
362+
headers = self._headers().copy()
363+
url = f"{self.api}/{entity_set}"
364+
params = {"sql": sql}
365+
r = self._request("get", url, headers=headers, params=params)
366+
try:
367+
r.raise_for_status()
368+
except Exception as e:
369+
# Attach response snippet to aid debugging unsupported SQL patterns
370+
resp_text = None
371+
try:
372+
resp_text = r.text[:500] if getattr(r, 'text', None) else None
373+
except Exception:
374+
pass
375+
detail = f" SQL query failed (status={getattr(r, 'status_code', '?')}): {resp_text}" if resp_text else ""
376+
raise RuntimeError(str(e) + detail) from e
377+
try:
378+
body = r.json()
379+
except ValueError:
380+
return []
381+
if isinstance(body, dict):
382+
value = body.get("value")
383+
if isinstance(value, list):
384+
# Ensure dict rows only
385+
return [row for row in value if isinstance(row, dict)]
386+
# Fallbacks: if body itself is a list
387+
if isinstance(body, list):
388+
return [row for row in body if isinstance(row, dict)]
389+
return []
390+
391+
# ---------------------- Entity set resolution -----------------------
392+
def _entity_set_from_logical(self, logical: str) -> str:
393+
"""Resolve entity set name (plural) from a logical (singular) name using metadata.
394+
395+
Caches results for subsequent SQL queries.
396+
"""
397+
if not logical:
398+
raise ValueError("logical name required")
399+
cached = self._logical_to_entityset_cache.get(logical)
400+
if cached:
401+
return cached
402+
url = f"{self.api}/EntityDefinitions"
403+
logical_escaped = self._escape_odata_quotes(logical)
404+
params = {
405+
"$select": "LogicalName,EntitySetName",
406+
"$filter": f"LogicalName eq '{logical_escaped}'",
407+
}
408+
r = self._request("get", url, headers=self._headers(), params=params)
343409
r.raise_for_status()
344-
data = r.json()
345-
if "queryresult" not in data:
346-
raise RuntimeError(f"{api_name} response missing 'queryresult'.")
347-
q = data["queryresult"]
348-
if q is None:
349-
parsed = []
350-
elif isinstance(q, str):
351-
s = q.strip()
352-
parsed = [] if not s else json.loads(s)
353-
else:
354-
raise RuntimeError(f"Unexpected queryresult type: {type(q)}")
355-
return parsed
410+
try:
411+
body = r.json()
412+
items = body.get("value", []) if isinstance(body, dict) else []
413+
except ValueError:
414+
items = []
415+
if not items:
416+
raise RuntimeError(f"Unable to resolve entity set for logical name '{logical}'.")
417+
es = items[0].get("EntitySetName")
418+
if not es:
419+
raise RuntimeError(f"Metadata response missing EntitySetName for logical '{logical}'.")
420+
self._logical_to_entityset_cache[logical] = es
421+
return es
356422

357423
# ---------------------- Table metadata helpers ----------------------
358424
def _label(self, text: str) -> Dict[str, Any]:

‎src/dataverse_sdk/odata_pandas_wrappers.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
DataFrame summarizing success/failure.
1515
* get_ids: fetches a set of ids returning a DataFrame of the merged JSON
1616
objects (outer union of keys). Missing keys are NaN.
17-
* query_sql_df: runs a SQL query via Custom API and returns the result rows as
17+
* query_sql_df: runs a SQL query via the Web API `?sql=` parameter and returns the result rows as
1818
a DataFrame (empty DataFrame if no rows).
1919
2020
Edge cases & behaviors:
@@ -140,8 +140,9 @@ def get_ids(self, entity_set: str, ids: Sequence[str] | pd.Series | pd.Index, se
140140

141141
# --------------------------- Query SQL -------------------------------
142142
def query_sql_df(self, tsql: str) -> pd.DataFrame:
143-
"""Execute a SQL query via Custom API and return a DataFrame.
143+
"""Execute a SQL query via the Dataverse Web API `?sql=` parameter and return a DataFrame.
144144
145+
The statement must adhere to the supported subset (single SELECT, optional WHERE/TOP/ORDER BY, no joins).
145146
Empty result -> empty DataFrame (columns inferred only if rows present).
146147
"""
147148
rows: Any = self._c.query_sql(tsql)

0 commit comments

Comments
 (0)