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
2429Not 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
8701117SQL-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