|
5 | 5 |
|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
8 | | -from typing import List, TYPE_CHECKING |
| 8 | +from typing import Any, Dict, List, Optional, TYPE_CHECKING |
9 | 9 |
|
10 | 10 | from ..models.record import Record |
11 | 11 |
|
@@ -149,3 +149,229 @@ def sql(self, sql: str) -> List[Record]: |
149 | 149 | with self._client._scoped_odata() as od: |
150 | 150 | rows = od._query_sql(sql) |
151 | 151 | 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