Skip to content

Commit 89adad3

Browse files
author
Saurabh Badenkal
committed
Add SQL helper functions: sql_columns, sql_select, sql_joins, sql_join
New methods on client.query for SQL-first developers: - sql_columns(table) -> simplified column metadata list - sql_select(table) -> comma-separated column list for SELECT - sql_joins(table) -> all possible JOINs with ready-to-use clauses - sql_join(from, to) -> auto-generated JOIN clause between tables Key finding from live testing: SQL JOINs use the raw attribute name (e.g. parentcustomerid), NOT the _value suffix. The ReferencingAttribute from relationship metadata matches exactly. 13 new unit tests, 745 total passing.
1 parent 12d3e86 commit 89adad3

3 files changed

Lines changed: 537 additions & 1 deletion

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,20 @@ results = client.query.sql("SELECT * FROM account")
404404
df = client.dataframe.sql(
405405
"SELECT name, revenue FROM account ORDER BY revenue DESC"
406406
)
407+
408+
# SQL helpers: discover columns and JOINs from metadata
409+
cols = client.query.sql_select("account") # "accountid, name, revenue, ..."
410+
join = client.query.sql_join("contact", "account", from_alias="c", to_alias="a")
411+
# Returns: "JOIN account a ON c.parentcustomerid = a.accountid"
412+
413+
# Build queries using helpers -- no OData knowledge needed
414+
sql = f"SELECT TOP 10 c.fullname, a.name FROM contact c {join}"
415+
df = client.dataframe.sql(sql)
416+
417+
# Discover all possible JOINs from a table (including polymorphic)
418+
joins = client.query.sql_joins("opportunity")
419+
for j in joins:
420+
print(f"{j['column']:30s} -> {j['target']}.{j['target_pk']}")
407421
```
408422

409423
**Raw OData queries** are available via `records.get()` for cases where you need direct control over the OData filter string:

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from typing import List, TYPE_CHECKING
8+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
99

1010
from ..models.record import Record
1111

@@ -149,3 +149,229 @@ def sql(self, sql: str) -> List[Record]:
149149
with self._client._scoped_odata() as od:
150150
rows = od._query_sql(sql)
151151
return [Record.from_api_response("", row) for row in rows]
152+
153+
# --------------------------------------------------------------- sql_columns
154+
155+
def sql_columns(
156+
self,
157+
table: str,
158+
*,
159+
include_system: bool = False,
160+
) -> List[Dict[str, Any]]:
161+
"""Return a simplified list of SQL-usable columns for a table.
162+
163+
Each dict contains ``name`` (logical name for SQL), ``type``
164+
(Dataverse attribute type), ``is_pk`` (primary key flag), and
165+
``label`` (display name). Virtual columns are always excluded
166+
because the SQL endpoint cannot query them.
167+
168+
:param table: Schema name of the table (e.g. ``"account"``).
169+
:type table: :class:`str`
170+
:param include_system: When ``False`` (default), columns that end
171+
with common system suffixes (``_base``, ``versionnumber``,
172+
``timezoneruleversionnumber``, ``utcconversiontimezonecode``,
173+
``importsequencenumber``, ``overriddencreatedon``) are excluded.
174+
:type include_system: :class:`bool`
175+
176+
:return: List of column metadata dicts.
177+
:rtype: list[dict[str, typing.Any]]
178+
179+
Example::
180+
181+
cols = client.query.sql_columns("account")
182+
for c in cols:
183+
print(f"{c['name']:30s} {c['type']:20s} PK={c['is_pk']}")
184+
"""
185+
_SYSTEM_SUFFIXES = (
186+
"_base",
187+
"versionnumber",
188+
"timezoneruleversionnumber",
189+
"utcconversiontimezonecode",
190+
"importsequencenumber",
191+
"overriddencreatedon",
192+
)
193+
194+
raw = self._client.tables.list_columns(
195+
table,
196+
select=["LogicalName", "SchemaName", "AttributeType", "IsPrimaryId", "IsPrimaryName", "DisplayName"],
197+
filter="AttributeType ne 'Virtual'",
198+
)
199+
result: List[Dict[str, Any]] = []
200+
for c in raw:
201+
name = c.get("LogicalName", "")
202+
if not name:
203+
continue
204+
if not include_system and any(name.endswith(s) for s in _SYSTEM_SUFFIXES):
205+
continue
206+
# Extract display label
207+
label = ""
208+
dn = c.get("DisplayName")
209+
if isinstance(dn, dict):
210+
ul = dn.get("UserLocalizedLabel")
211+
if isinstance(ul, dict):
212+
label = ul.get("Label", "")
213+
result.append(
214+
{
215+
"name": name,
216+
"type": c.get("AttributeType", ""),
217+
"is_pk": bool(c.get("IsPrimaryId")),
218+
"is_name": bool(c.get("IsPrimaryName")),
219+
"label": label,
220+
}
221+
)
222+
result.sort(key=lambda x: (not x["is_pk"], not x["is_name"], x["name"]))
223+
return result
224+
225+
# --------------------------------------------------------------- sql_select
226+
227+
def sql_select(
228+
self,
229+
table: str,
230+
*,
231+
include_system: bool = False,
232+
) -> str:
233+
"""Return a comma-separated column list for use in SQL SELECT.
234+
235+
Excludes virtual columns and optionally system columns. The result
236+
can be embedded directly in a SQL query string.
237+
238+
:param table: Schema name of the table (e.g. ``"account"``).
239+
:type table: :class:`str`
240+
:param include_system: Include system columns (default ``False``).
241+
:type include_system: :class:`bool`
242+
243+
:return: Comma-separated column names.
244+
:rtype: :class:`str`
245+
246+
Example::
247+
248+
cols = client.query.sql_select("account")
249+
sql = f"SELECT TOP 10 {cols} FROM account"
250+
df = client.dataframe.sql(sql)
251+
"""
252+
columns = self.sql_columns(table, include_system=include_system)
253+
return ", ".join(c["name"] for c in columns)
254+
255+
# --------------------------------------------------------------- sql_joins
256+
257+
def sql_joins(
258+
self,
259+
table: str,
260+
) -> List[Dict[str, Any]]:
261+
"""Discover all possible SQL JOINs from a table.
262+
263+
Returns one entry per outgoing lookup relationship, with the
264+
exact column names needed for SQL ``JOIN ... ON`` clauses.
265+
266+
For **polymorphic** lookups (e.g. ``customerid`` targeting both
267+
``account`` and ``contact``), multiple entries are returned with
268+
the same ``column`` but different ``target`` values.
269+
270+
:param table: Schema name of the table (e.g. ``"contact"``).
271+
:type table: :class:`str`
272+
273+
:return: List of JOIN metadata dicts, each containing:
274+
275+
- ``column`` -- the lookup attribute on this table (use in ON clause)
276+
- ``target`` -- the referenced entity name
277+
- ``target_pk`` -- the referenced entity's primary key column
278+
- ``relationship`` -- the schema name of the relationship
279+
- ``join_clause`` -- a ready-to-use ``JOIN ... ON ...`` fragment
280+
281+
:rtype: list[dict[str, typing.Any]]
282+
283+
Example::
284+
285+
joins = client.query.sql_joins("contact")
286+
for j in joins:
287+
print(f"{j['column']:30s} -> {j['target']}.{j['target_pk']}")
288+
print(f" {j['join_clause']}")
289+
290+
# Use in a query
291+
j = next(j for j in joins if j['target'] == 'account')
292+
sql = f"SELECT TOP 10 c.fullname, a.name FROM contact c {j['join_clause']}"
293+
"""
294+
table_lower = table.lower()
295+
rels = self._client.tables.list_table_relationships(table)
296+
297+
result: List[Dict[str, Any]] = []
298+
for r in rels:
299+
ref_entity = (r.get("ReferencingEntity") or "").lower()
300+
if ref_entity != table_lower:
301+
continue
302+
col = r.get("ReferencingAttribute", "")
303+
target = r.get("ReferencedEntity", "")
304+
target_pk = r.get("ReferencedAttribute", "")
305+
schema = r.get("SchemaName", "")
306+
if not all([col, target, target_pk]):
307+
continue
308+
309+
# Generate a short alias for the target table
310+
alias = target[0] if target else "j"
311+
join_clause = f"JOIN {target} {alias} " f"ON {table_lower}.{col} = {alias}.{target_pk}"
312+
313+
result.append(
314+
{
315+
"column": col,
316+
"target": target,
317+
"target_pk": target_pk,
318+
"relationship": schema,
319+
"join_clause": join_clause,
320+
}
321+
)
322+
323+
result.sort(key=lambda x: (x["target"], x["column"]))
324+
return result
325+
326+
# --------------------------------------------------------------- sql_join
327+
328+
def sql_join(
329+
self,
330+
from_table: str,
331+
to_table: str,
332+
*,
333+
from_alias: Optional[str] = None,
334+
to_alias: Optional[str] = None,
335+
) -> str:
336+
"""Generate a SQL JOIN clause between two tables.
337+
338+
Discovers the relationship automatically via metadata. If multiple
339+
relationships exist (e.g. polymorphic lookups), picks the first
340+
match. Use :meth:`sql_joins` to see all options.
341+
342+
:param from_table: Schema name of the FROM table (e.g. ``"contact"``).
343+
:type from_table: :class:`str`
344+
:param to_table: Schema name of the target table (e.g. ``"account"``).
345+
:type to_table: :class:`str`
346+
:param from_alias: Optional alias for the FROM table in the JOIN
347+
clause. If ``None``, uses the full table name.
348+
:type from_alias: :class:`str` or None
349+
:param to_alias: Optional alias for the target table. If ``None``,
350+
uses the first letter of the target table name.
351+
:type to_alias: :class:`str` or None
352+
353+
:return: A ready-to-use ``JOIN ... ON ...`` clause.
354+
:rtype: :class:`str`
355+
356+
:raises ValueError: If no relationship is found between the tables.
357+
358+
Example::
359+
360+
j = client.query.sql_join("contact", "account", from_alias="c", to_alias="a")
361+
# Returns: "JOIN account a ON c.parentcustomerid = a.accountid"
362+
sql = f"SELECT TOP 10 c.fullname, a.name FROM contact c {j}"
363+
df = client.dataframe.sql(sql)
364+
"""
365+
to_lower = to_table.lower()
366+
joins = self.sql_joins(from_table)
367+
match = [j for j in joins if j["target"].lower() == to_lower]
368+
if not match:
369+
raise ValueError(
370+
f"No relationship found from '{from_table}' to '{to_table}'. "
371+
f"Use client.query.sql_joins('{from_table}') to see available targets."
372+
)
373+
374+
j = match[0]
375+
src = from_alias or from_table.lower()
376+
tgt = to_alias or to_lower[0]
377+
return f"JOIN {to_lower} {tgt} " f"ON {src}.{j['column']} = {tgt}.{j['target_pk']}"

0 commit comments

Comments
 (0)