Skip to content

Commit 3945b8b

Browse files
author
Saurabh Badenkal
committed
Update sql_examples.py: add AND/OR, NOT IN, deep JOINs, helpers, SQL vs OData comparison
New sections (27-31): - 27: AND/OR, NOT IN, NOT LIKE boolean logic - 28: Deep JOINs (5-8 tables) with built-in tables - 29: SQL helper functions (sql_columns, sql_select, sql_joins, sql_join) - 30: OData helper functions (odata_select, odata_expands, odata_expand, odata_bind) - 31: SQL vs OData side-by-side comparison with live benchmark - 32: Updated summary table with all new features - 33: Cleanup Summary table now includes: AND/OR, NOT IN/LIKE, 8+ table JOINs, nested polymorphic, self-JOIN, DISTINCT+JOIN, all helper functions. SQL-first workflow updated with helper-driven steps. 756 unit tests passing.
1 parent e251f42 commit 3945b8b

1 file changed

Lines changed: 264 additions & 15 deletions

File tree

examples/advanced/sql_examples.py

Lines changed: 264 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
- Polymorphic lookups (ownerid, customerid) via separate JOINs
2121
- Audit trail (createdby, modifiedby) via systemuser JOINs
2222
- SQL read -> DataFrame transform -> SDK write-back (full round-trip)
23+
- AND/OR, NOT IN, NOT LIKE boolean logic
24+
- Deep JOINs (5-8 tables) with no server depth limit
25+
- SQL helper functions: sql_columns, sql_select, sql_joins, sql_join
26+
- OData helper functions: odata_select, odata_expands, odata_expand, odata_bind
27+
- SQL vs OData side-by-side comparison
2328
2429
Not supported (server rejects):
2530
- INSERT/UPDATE/DELETE (read-only) -> use client.dataframe.create/update/delete
@@ -829,31 +834,275 @@ def _run_examples(client):
829834
backoff(lambda kid=kid: client.records.delete(child_table, kid))
830835

831836
# ==============================================================
832-
# 27. Summary
837+
# 27. AND/OR, NOT IN, NOT LIKE
833838
# ==============================================================
834-
heading(27, "Summary -- SQL Capabilities Reference")
839+
heading(27, "SQL -- AND/OR, NOT IN, NOT LIKE")
840+
sql = f"SELECT new_code, new_budget FROM {parent_table} " f"WHERE new_active = 1 AND new_budget > 40000"
841+
log_call(f'client.query.sql("{sql}")')
842+
results = backoff(lambda: client.query.sql(sql))
843+
print(f"[OK] AND: {len(results)} rows")
844+
845+
sql = f"SELECT new_code FROM {parent_table} " f"WHERE new_code = 'ALPHA' OR new_code = 'DELTA'"
846+
results = backoff(lambda: client.query.sql(sql))
847+
print(f"[OK] OR: {[r.get('new_code') for r in results]}")
848+
849+
sql = (
850+
f"SELECT new_code FROM {parent_table} "
851+
f"WHERE new_active = 1 AND (new_budget > 80000 OR new_budget < 45000)"
852+
)
853+
results = backoff(lambda: client.query.sql(sql))
854+
print(f"[OK] AND + OR with parens: {len(results)} rows")
855+
856+
sql = f"SELECT new_code FROM {parent_table} WHERE new_code NOT IN ('ALPHA')"
857+
results = backoff(lambda: client.query.sql(sql))
858+
print(f"[OK] NOT IN: {[r.get('new_code') for r in results]}")
859+
860+
sql = f"SELECT new_title FROM {child_table} WHERE new_title NOT LIKE 'Design%'"
861+
results = backoff(lambda: client.query.sql(sql))
862+
print(f"[OK] NOT LIKE: {len(results)} rows")
863+
864+
# ==============================================================
865+
# 28. Deep JOINs (5-8 tables)
866+
# ==============================================================
867+
heading(28, "Deep JOINs (5-8 Tables) -- Built-In Tables")
868+
print(
869+
"SQL JOINs support at least 8 tables with no server depth limit.\n"
870+
"Performance stays consistent (~0.7s for 8-table JOINs)."
871+
)
872+
873+
sql = (
874+
"SELECT TOP 3 a.name, c.fullname, o.name as opp, "
875+
"su.fullname as owner, bu.name as bu "
876+
"FROM account a "
877+
"JOIN contact c ON a.accountid = c.parentcustomerid "
878+
"JOIN opportunity o ON a.accountid = o.parentaccountid "
879+
"JOIN systemuser su ON a.ownerid = su.systemuserid "
880+
"JOIN businessunit bu ON su.businessunitid = bu.businessunitid"
881+
)
882+
log_call("5-table JOIN")
883+
try:
884+
results = backoff(lambda: client.query.sql(sql))
885+
print(f"[OK] 5-table JOIN: {len(results)} rows")
886+
except Exception as e:
887+
print(f"[INFO] {e}")
888+
889+
sql = (
890+
"SELECT TOP 3 a.name, c.fullname, o.name as opp, "
891+
"su.fullname as owner, bu.name as bu, t.name as team, "
892+
"cr.fullname as creator, md.fullname as modifier "
893+
"FROM account a "
894+
"JOIN contact c ON a.accountid = c.parentcustomerid "
895+
"JOIN opportunity o ON a.accountid = o.parentaccountid "
896+
"JOIN systemuser su ON a.ownerid = su.systemuserid "
897+
"JOIN businessunit bu ON su.businessunitid = bu.businessunitid "
898+
"JOIN team t ON bu.businessunitid = t.businessunitid "
899+
"JOIN systemuser cr ON a.createdby = cr.systemuserid "
900+
"JOIN systemuser md ON a.modifiedby = md.systemuserid"
901+
)
902+
log_call("8-table JOIN")
903+
try:
904+
results = backoff(lambda: client.query.sql(sql))
905+
print(f"[OK] 8-table JOIN: {len(results)} rows")
906+
except Exception as e:
907+
print(f"[INFO] {e}")
908+
909+
# ==============================================================
910+
# 29. SQL Helper Functions
911+
# ==============================================================
912+
heading(29, "SQL Helper Functions (query.sql_*)")
913+
print(
914+
"The SDK provides helper functions that auto-discover column\n"
915+
"names and JOIN clauses from metadata -- no guessing needed."
916+
)
917+
918+
# sql_columns
919+
log_call(f"client.query.sql_columns('{parent_table}')")
920+
cols = client.query.sql_columns(parent_table)
921+
print(f"[OK] {len(cols)} columns:")
922+
for c in cols[:5]:
923+
print(f" {c['name']:30s} Type: {c['type']:15s} PK={c['is_pk']}")
924+
925+
# sql_select
926+
log_call(f"client.query.sql_select('{parent_table}')")
927+
select_str = client.query.sql_select(parent_table)
928+
print(f"[OK] SELECT list: {select_str[:60]}...")
929+
930+
# sql_joins
931+
log_call(f"client.query.sql_joins('{child_table}')")
932+
joins = client.query.sql_joins(child_table)
933+
print(f"[OK] {len(joins)} possible JOINs:")
934+
for j in joins[:5]:
935+
print(f" {j['column']:25s} -> {j['target']}.{j['target_pk']}")
936+
937+
# sql_join (auto-generate JOIN clause)
938+
log_call(f"client.query.sql_join('{child_table}', '{parent_table}', ...)")
939+
try:
940+
join_clause = client.query.sql_join(child_table, parent_table, from_alias="tk", to_alias="t")
941+
print(f"[OK] {join_clause}")
942+
943+
sql = f"SELECT TOP 3 tk.new_title, t.new_code FROM {child_table} tk {join_clause}"
944+
results = backoff(lambda: client.query.sql(sql))
945+
print(f"[OK] Live query with sql_join(): {len(results)} rows")
946+
except Exception as e:
947+
print(f"[WARN] {e}")
948+
949+
# ==============================================================
950+
# 30. OData Helper Functions
951+
# ==============================================================
952+
heading(30, "OData Helper Functions (query.odata_*)")
953+
print(
954+
"Parallel helpers for OData/records.get() users -- auto-discover\n"
955+
"navigation properties and build @odata.bind payloads."
956+
)
957+
958+
# odata_select
959+
log_call(f"client.query.odata_select('{parent_table}')")
960+
odata_cols = client.query.odata_select(parent_table)
961+
print(f"[OK] {len(odata_cols)} columns for $select: {odata_cols[:5]}...")
962+
963+
# odata_expands
964+
log_call(f"client.query.odata_expands('{child_table}')")
965+
try:
966+
expands = client.query.odata_expands(child_table)
967+
print(f"[OK] {len(expands)} expand targets:")
968+
for e in expands[:5]:
969+
print(f" nav={e['nav_property']:30s} -> {e['target_table']}")
970+
except Exception as e:
971+
print(f"[WARN] {e}")
972+
973+
# odata_expand (single target)
974+
try:
975+
nav = client.query.odata_expand(child_table, parent_table)
976+
print(f"\n[OK] odata_expand('{child_table}', '{parent_table}') = '{nav}'")
977+
print(" Usage: client.records.get('" + child_table + "', expand=['" + nav + "'])")
978+
except Exception as e:
979+
print(f"[WARN] {e}")
980+
981+
# odata_bind
982+
log_call("client.query.odata_bind(...)")
983+
try:
984+
bind = client.query.odata_bind(child_table, parent_table, team_ids[0])
985+
print(f"[OK] {bind}")
986+
print(" Merge into create/update payload: {{'new_Title': 'X', **bind}}")
987+
except Exception as e:
988+
print(f"[WARN] {e}")
989+
990+
# ==============================================================
991+
# 31. SQL vs OData Comparison
992+
# ==============================================================
993+
heading(31, "SQL vs OData -- Side-by-Side Comparison")
994+
print("Both SQL and OData can query Dataverse. Here's how they compare.")
995+
996+
print("""
997+
+-------------------------------+------------------------+------------------------+
998+
| Capability | SQL (client.query.sql) | OData (records.get) |
999+
+-------------------------------+------------------------+------------------------+
1000+
| Read data | YES | YES |
1001+
| Write data | NO (read-only) | YES (create/update/del)|
1002+
| JOIN depth | 8+ tables (no limit) | $expand 10-level max |
1003+
| JOIN types | INNER, LEFT | $expand (single-valued)|
1004+
| Aggregates (COUNT, SUM, etc.) | YES (server-side) | Limited ($apply) |
1005+
| GROUP BY | YES (server-side) | Via $apply (complex) |
1006+
| DISTINCT | YES | Not directly |
1007+
| Pagination | OFFSET FETCH | @odata.nextLink |
1008+
| Max results | 5000 per query | 5000 per page |
1009+
| Column discovery | sql_columns/sql_select | odata_select |
1010+
| JOIN discovery | sql_joins/sql_join | odata_expands/expand |
1011+
| Lookup binding | N/A (read-only) | odata_bind |
1012+
| SELECT * | YES (SDK auto-expands) | Not applicable |
1013+
| Polymorphic lookups | Separate JOINs | $expand by nav prop |
1014+
| Return format | list[Record] / DF | pages of Record / DF |
1015+
| Subqueries | NO (chain SQL calls) | NO ($filter only) |
1016+
| Functions (CASE, CAST, etc.) | NO | NO |
1017+
+-------------------------------+------------------------+------------------------+
1018+
1019+
When to use SQL:
1020+
- Complex JOINs across 3+ tables
1021+
- Aggregates and GROUP BY
1022+
- DISTINCT queries
1023+
- Familiar SQL syntax preferred
1024+
- Read-only analysis / reporting
1025+
1026+
When to use OData (records.get):
1027+
- Need to write data (create/update/delete)
1028+
- Simple single-table or 1-level expand queries
1029+
- Need automatic paging (nextLink)
1030+
- Prefer typed QueryBuilder API
1031+
""")
1032+
1033+
# Live comparison: same query via SQL and OData
1034+
print("-- Live comparison: account + contact --")
1035+
import time as _time
1036+
1037+
# SQL version
1038+
t0 = _time.time()
1039+
try:
1040+
sql_rows = backoff(
1041+
lambda: client.query.sql(
1042+
"SELECT TOP 5 a.name, c.fullname "
1043+
"FROM account a "
1044+
"JOIN contact c ON a.accountid = c.parentcustomerid"
1045+
)
1046+
)
1047+
sql_time = _time.time() - t0
1048+
print(f" SQL JOIN: {len(sql_rows)} rows in {sql_time:.2f}s")
1049+
except Exception as e:
1050+
sql_time = _time.time() - t0
1051+
print(f" SQL JOIN: error ({sql_time:.2f}s): {e}")
1052+
1053+
# OData version (expand)
1054+
t0 = _time.time()
1055+
try:
1056+
odata_rows = []
1057+
for page in backoff(
1058+
lambda: client.records.get(
1059+
"account",
1060+
select=["name"],
1061+
expand=["contact_customer_accounts"],
1062+
top=5,
1063+
)
1064+
):
1065+
odata_rows.extend(page)
1066+
odata_time = _time.time() - t0
1067+
print(f" OData $expand: {len(odata_rows)} rows in {odata_time:.2f}s")
1068+
except Exception as e:
1069+
odata_time = _time.time() - t0
1070+
print(f" OData $expand: error ({odata_time:.2f}s): {e}")
1071+
1072+
# ==============================================================
1073+
# 32. Summary
1074+
# ==============================================================
1075+
heading(32, "Summary -- SQL Capabilities Reference")
8351076
print("""
8361077
+-------------------------------+----------+----------------------------------------+
8371078
| Feature | SQL | Notes / SDK Fallback |
8381079
+-------------------------------+----------+----------------------------------------+
8391080
| SELECT col1, col2 | YES | Use LogicalName (lowercase) |
8401081
| SELECT * | YES (*) | SDK auto-expands via list_columns() |
8411082
| WHERE =, !=, >, <, LIKE, IN | YES | |
1083+
| AND, OR, parentheses | YES | Full boolean logic |
1084+
| NOT IN, NOT LIKE | YES | |
8421085
| IS NULL, IS NOT NULL, BETWEEN | YES | |
8431086
| TOP N (0-5000) | YES | Max 5000 per query |
844-
| ORDER BY col [ASC|DESC] | YES | Column names only |
1087+
| ORDER BY col [ASC|DESC] | YES | Multiple columns supported |
8451088
| OFFSET n FETCH NEXT m | YES | Server-side pagination |
8461089
| Table/Column aliases | YES | |
847-
| DISTINCT / DISTINCT TOP | YES | |
1090+
| DISTINCT / DISTINCT TOP | YES | Works with JOINs too |
8481091
| COUNT, SUM, AVG, MIN, MAX | YES | All 5 standard aggregates |
8491092
| GROUP BY | YES | Server-side grouping |
850-
| INNER JOIN | YES | Up to 6+ tables tested |
1093+
| INNER JOIN | YES | 8+ tables tested (no depth limit) |
8511094
| LEFT JOIN | YES | |
852-
| Self JOIN | YES | |
1095+
| Self JOIN | YES | Same table with different aliases |
8531096
| SQL -> DataFrame | YES | client.dataframe.sql(query) |
854-
| Polymorphic lookups (ownerid) | YES | Separate JOINs per target type |
1097+
| Polymorphic lookups | YES | Separate JOINs per target type |
1098+
| Nested polymorphic chains | YES | e.g. opp -> acct -> contact -> owner |
8551099
| Audit trail (createdby, etc.) | YES | JOIN to systemuser |
8561100
| SQL read -> DF write-back | YES | dataframe.sql() + .update()/.create() |
1101+
| SQL column discovery | YES | query.sql_columns() / sql_select() |
1102+
| SQL JOIN discovery | YES | query.sql_joins() / sql_join() |
1103+
| OData column discovery | YES | query.odata_select() |
1104+
| OData expand discovery | YES | query.odata_expands() / odata_expand() |
1105+
| OData bind builder | YES | query.odata_bind() |
8571106
+-------------------------------+----------+----------------------------------------+
8581107
| HAVING | NO | Filter before GROUP BY |
8591108
| Subqueries / CTE | NO | Chain multiple SQL calls |
@@ -863,20 +1112,20 @@ def _run_examples(client):
8631112
| String/Date/Math functions | NO | Post-process in Python/pandas |
8641113
| Window fns (ROW_NUMBER, RANK) | NO | Post-process in Python/pandas |
8651114
| INSERT / UPDATE / DELETE | NO | dataframe.create/update/delete() |
866-
| Schema discovery | -- | tables.list_columns() |
867-
| Relationship discovery | -- | tables.list_table_relationships() |
8681115
+-------------------------------+----------+----------------------------------------+
8691116
8701117
SQL-First Workflow (no OData knowledge needed):
871-
1. Discover schema: client.tables.list_columns("account")
872-
2. Query with SQL: df = client.dataframe.sql("SELECT ...")
873-
3. Transform: df["col"] = df["col"] * 1.1
874-
4. Write back: client.dataframe.update("account", df, id_column="accountid")
875-
5. Verify: df2 = client.dataframe.sql("SELECT ...")
1118+
1. Discover schema: cols = client.query.sql_columns("account")
1119+
2. Discover JOINs: joins = client.query.sql_joins("contact")
1120+
3. Build JOIN: j = client.query.sql_join("contact", "account", from_alias="c", to_alias="a")
1121+
4. Query with SQL: df = client.dataframe.sql(f"SELECT c.fullname, a.name FROM contact c {j}")
1122+
5. Transform: df["col"] = df["col"] * 1.1
1123+
6. Write back: client.dataframe.update("account", df, id_column="accountid")
1124+
7. Verify: df2 = client.dataframe.sql("SELECT ...")
8761125
""")
8771126

8781127
finally:
879-
heading(28, "Cleanup")
1128+
heading(33, "Cleanup")
8801129
for tbl in [child_table, parent_table]:
8811130
log_call(f"client.tables.delete('{tbl}')")
8821131
try:

0 commit comments

Comments
 (0)