databricks-explore 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""SQL helpers built on sqlglot. No regex parsing of SQL.
|
|
2
|
+
|
|
3
|
+
Owners: wrap_limit -> task A1 ; expand_refs, tables_read -> task A3.
|
|
4
|
+
Each function is a stub until its owner implements it.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Iterable, NamedTuple
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
from sqlglot.optimizer.scope import ScopeType, build_scope, traverse_scope
|
|
13
|
+
from sqlglot.tokens import TokenType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def wrap_limit(sql: str, max_rows: int) -> tuple[str, bool]:
|
|
17
|
+
"""Return (wrapped_sql, can_detect_truncation).
|
|
18
|
+
|
|
19
|
+
Wraps as SELECT * FROM (<sql>) LIMIT max_rows+1 so truncation is detectable.
|
|
20
|
+
If sqlglot cannot parse the statement, return (sql, False) unchanged.
|
|
21
|
+
Statements that parse but are not queries (SHOW, DESCRIBE, DDL, multi-statement
|
|
22
|
+
blocks) cannot be wrapped either, so they also pass through unchanged.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
tree = sqlglot.parse_one(sql, read="databricks")
|
|
26
|
+
except sqlglot.errors.SqlglotError:
|
|
27
|
+
return sql, False
|
|
28
|
+
if not isinstance(tree, exp.Query):
|
|
29
|
+
return sql, False
|
|
30
|
+
wrapped = exp.select("*").from_(exp.Subquery(this=tree)).limit(int(max_rows) + 1)
|
|
31
|
+
return wrapped.sql(dialect="databricks"), True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DIALECT = "databricks"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tokenize(sql: str) -> list:
|
|
38
|
+
try:
|
|
39
|
+
return sqlglot.tokenize(sql, read=_DIALECT)
|
|
40
|
+
except sqlglot.errors.SqlglotError as e:
|
|
41
|
+
raise ValueError(f"cannot tokenize SQL: {e}") from e
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse(sql: str) -> exp.Expression:
|
|
45
|
+
try:
|
|
46
|
+
return sqlglot.parse_one(sql, read=_DIALECT)
|
|
47
|
+
except sqlglot.errors.SqlglotError as e:
|
|
48
|
+
raise ValueError(f"cannot parse SQL: {e}") from e
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _find_refs(sql: str) -> list[tuple[int, int, str]]:
|
|
52
|
+
"""Locate each {{title}} as (start, end_exclusive, title), in source order.
|
|
53
|
+
|
|
54
|
+
Works on sqlglot tokens, so braces inside string literals and comments are
|
|
55
|
+
ignored. Titles are the raw text between the braces, stripped.
|
|
56
|
+
"""
|
|
57
|
+
toks = _tokenize(sql)
|
|
58
|
+
refs = []
|
|
59
|
+
i = 0
|
|
60
|
+
while i < len(toks) - 1:
|
|
61
|
+
if toks[i].token_type == TokenType.L_BRACE and toks[i + 1].token_type == TokenType.L_BRACE:
|
|
62
|
+
j = i + 2
|
|
63
|
+
while j < len(toks) - 1 and not (
|
|
64
|
+
toks[j].token_type == TokenType.R_BRACE and toks[j + 1].token_type == TokenType.R_BRACE
|
|
65
|
+
):
|
|
66
|
+
j += 1
|
|
67
|
+
if j >= len(toks) - 1:
|
|
68
|
+
raise ValueError(f"unclosed {{{{ reference at offset {toks[i].start}")
|
|
69
|
+
title = sql[toks[i + 1].end + 1 : toks[j].start].strip()
|
|
70
|
+
if not title or "{" in title or "}" in title:
|
|
71
|
+
raise ValueError(f"invalid window reference at offset {toks[i].start}: {title!r}")
|
|
72
|
+
refs.append((toks[i].start, toks[j + 1].end + 1, title))
|
|
73
|
+
i = j + 2
|
|
74
|
+
else:
|
|
75
|
+
i += 1
|
|
76
|
+
return refs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _splice(sql: str, refs: list[tuple[int, int, str]], texts: dict[str, str]) -> str:
|
|
80
|
+
"""Replace each located reference with texts[title]."""
|
|
81
|
+
out = []
|
|
82
|
+
pos = 0
|
|
83
|
+
for start, end, title in refs:
|
|
84
|
+
out.append(sql[pos:start])
|
|
85
|
+
out.append(texts[title])
|
|
86
|
+
pos = end
|
|
87
|
+
out.append(sql[pos:])
|
|
88
|
+
return "".join(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _in_source_order(node: exp.Expression):
|
|
92
|
+
"""Depth-first walk that visits a WITH clause before the query body it precedes."""
|
|
93
|
+
yield node
|
|
94
|
+
for key in sorted(node.args, key=lambda k: k != "with_"):
|
|
95
|
+
value = node.args[key]
|
|
96
|
+
for child in value if isinstance(value, list) else [value]:
|
|
97
|
+
if isinstance(child, exp.Expression):
|
|
98
|
+
yield from _in_source_order(child)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def expand_refs(sql: str, windows_by_title: dict[str, str]) -> str:
|
|
102
|
+
"""Replace {{title}} with a CTE built from that window's SQL, recursively.
|
|
103
|
+
|
|
104
|
+
Every referenced window becomes one CTE in a single WITH clause, placed before
|
|
105
|
+
the statement's own CTEs. Windows come out dependencies-first. The CTE name is
|
|
106
|
+
the sanitized title, made unique against identifiers already in the SQL.
|
|
107
|
+
|
|
108
|
+
Raises ValueError naming the cycle if references form a loop, and ValueError
|
|
109
|
+
naming the title if a reference is unknown.
|
|
110
|
+
"""
|
|
111
|
+
outer_refs = _find_refs(sql)
|
|
112
|
+
if not outer_refs:
|
|
113
|
+
return sql
|
|
114
|
+
|
|
115
|
+
# Depth-first post-order: each window is recorded after the windows it uses.
|
|
116
|
+
order: list[str] = []
|
|
117
|
+
done: set[str] = set()
|
|
118
|
+
path: list[str] = []
|
|
119
|
+
|
|
120
|
+
def visit(title: str) -> None:
|
|
121
|
+
if title in path:
|
|
122
|
+
cycle = path[path.index(title):] + [title]
|
|
123
|
+
raise ValueError("cycle: " + " -> ".join(cycle))
|
|
124
|
+
if title in done:
|
|
125
|
+
return
|
|
126
|
+
if title not in windows_by_title:
|
|
127
|
+
raise ValueError(f"unknown window title: {title!r}")
|
|
128
|
+
path.append(title)
|
|
129
|
+
for _, _, dep in _find_refs(windows_by_title[title]):
|
|
130
|
+
visit(dep)
|
|
131
|
+
path.pop()
|
|
132
|
+
done.add(title)
|
|
133
|
+
order.append(title)
|
|
134
|
+
|
|
135
|
+
for _, _, title in outer_refs:
|
|
136
|
+
visit(title)
|
|
137
|
+
|
|
138
|
+
# Identifiers already used anywhere in the statement, so a CTE name cannot
|
|
139
|
+
# capture a real table or column reference.
|
|
140
|
+
def without_refs(text: str) -> str:
|
|
141
|
+
refs = _find_refs(text)
|
|
142
|
+
return _splice(text, refs, dict.fromkeys((t for _, _, t in refs), " "))
|
|
143
|
+
|
|
144
|
+
texts = [without_refs(sql), *(without_refs(windows_by_title[t]) for t in order)]
|
|
145
|
+
taken = {tok.text.lower() for text in texts for tok in _tokenize(text)}
|
|
146
|
+
names: dict[str, str] = {}
|
|
147
|
+
for title in order:
|
|
148
|
+
base = "".join(c if (c.isalnum() or c == "_") else "_" for c in title)
|
|
149
|
+
name, k = base, 2
|
|
150
|
+
while name.lower() in taken:
|
|
151
|
+
name = f"{base}_{k}"
|
|
152
|
+
k += 1
|
|
153
|
+
taken.add(name.lower())
|
|
154
|
+
names[title] = name
|
|
155
|
+
|
|
156
|
+
quoted = {t: exp.to_identifier(names[t], quoted=True).sql(dialect=_DIALECT) for t in order}
|
|
157
|
+
|
|
158
|
+
def replace_refs(text: str) -> str:
|
|
159
|
+
refs = _find_refs(text)
|
|
160
|
+
return _splice(text, refs, {t: quoted[t] for _, _, t in refs})
|
|
161
|
+
|
|
162
|
+
# The user's SQL is kept as text and only the WITH clause is added. Rebuilding
|
|
163
|
+
# from the sqlglot AST would hoist nested WITH clauses to the top level, which
|
|
164
|
+
# can make two windows' private CTEs collide.
|
|
165
|
+
ctes = []
|
|
166
|
+
for title in order:
|
|
167
|
+
body = replace_refs(windows_by_title[title])
|
|
168
|
+
_parse(body) # validate
|
|
169
|
+
ctes.append(f"{quoted[title]} AS (\n{_strip_trailing_semicolons(body)}\n)")
|
|
170
|
+
ctes_block = ",\n".join(ctes)
|
|
171
|
+
|
|
172
|
+
outer = replace_refs(sql)
|
|
173
|
+
_parse(outer) # validate
|
|
174
|
+
toks = _tokenize(outer)
|
|
175
|
+
if toks and toks[0].token_type == TokenType.WITH:
|
|
176
|
+
# Merge into the statement's own WITH clause, ahead of its CTEs.
|
|
177
|
+
keyword_end = toks[0].end + 1
|
|
178
|
+
if len(toks) > 1 and toks[1].text.upper() == "RECURSIVE":
|
|
179
|
+
keyword_end = toks[1].end + 1
|
|
180
|
+
return f"{outer[:keyword_end]}\n{ctes_block},{outer[keyword_end:]}"
|
|
181
|
+
return f"WITH\n{ctes_block}\n{outer}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _strip_trailing_semicolons(text: str) -> str:
|
|
185
|
+
toks = _tokenize(text)
|
|
186
|
+
while toks and toks[-1].token_type == TokenType.SEMICOLON:
|
|
187
|
+
text = text[: toks[-1].start]
|
|
188
|
+
toks = toks[:-1]
|
|
189
|
+
return text.rstrip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def tables_read(sql: str) -> list[str]:
|
|
193
|
+
"""Return the fully qualified table names (catalog.schema.table) the statement reads.
|
|
194
|
+
|
|
195
|
+
Names are kept as written (identifier text, no quotes), joined with dots, and
|
|
196
|
+
de-duplicated in first-seen order. CTE names defined in the statement are
|
|
197
|
+
excluded, as are {{title}} window references and the target of INSERT, CREATE,
|
|
198
|
+
and MERGE statements.
|
|
199
|
+
"""
|
|
200
|
+
refs = _find_refs(sql)
|
|
201
|
+
if refs:
|
|
202
|
+
# A window reference is not a table. Swap each for a placeholder name so the
|
|
203
|
+
# statement parses, then drop the placeholder below.
|
|
204
|
+
placeholders: dict[str, str] = {}
|
|
205
|
+
for _, _, title in refs:
|
|
206
|
+
placeholders.setdefault(title, f"__window_ref_{len(placeholders)}")
|
|
207
|
+
sql = _splice(sql, refs, placeholders)
|
|
208
|
+
placeholder_names = set(placeholders.values())
|
|
209
|
+
else:
|
|
210
|
+
placeholder_names = set()
|
|
211
|
+
|
|
212
|
+
tree = _parse(sql)
|
|
213
|
+
if tree is None:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
cte_names = {cte.alias.lower() for cte in tree.find_all(exp.CTE)}
|
|
217
|
+
write_targets = set()
|
|
218
|
+
for stmt in tree.find_all(exp.Insert, exp.Create, exp.Merge):
|
|
219
|
+
target = stmt.this
|
|
220
|
+
if isinstance(target, exp.Schema):
|
|
221
|
+
target = target.this
|
|
222
|
+
if isinstance(target, exp.Table):
|
|
223
|
+
write_targets.add(id(target))
|
|
224
|
+
|
|
225
|
+
result: list[str] = []
|
|
226
|
+
seen: set[str] = set()
|
|
227
|
+
for node in _in_source_order(tree):
|
|
228
|
+
if not isinstance(node, exp.Table) or id(node) in write_targets:
|
|
229
|
+
continue
|
|
230
|
+
if not isinstance(node.this, exp.Identifier):
|
|
231
|
+
continue # table-valued function, not a table name
|
|
232
|
+
parts = [p.name for p in node.parts]
|
|
233
|
+
if len(parts) == 1 and (parts[0] in placeholder_names or parts[0].lower() in cte_names):
|
|
234
|
+
continue
|
|
235
|
+
full = ".".join(parts)
|
|
236
|
+
if full not in seen:
|
|
237
|
+
seen.add(full)
|
|
238
|
+
result.append(full)
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------- join extraction ----------
|
|
243
|
+
|
|
244
|
+
class JoinPair(NamedTuple):
|
|
245
|
+
"""Two columns a statement equates. The two ends are in sorted order, so a pair has one spelling."""
|
|
246
|
+
left_table: str
|
|
247
|
+
left_column: str
|
|
248
|
+
right_table: str
|
|
249
|
+
right_column: str
|
|
250
|
+
join_type: str # inner, left, right, full, semi, anti, implicit (comma or cross join plus WHERE), correlated
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _Names:
|
|
254
|
+
"""Qualifies table names to catalog.schema.table, in lowercase, from defaults and a set of known names."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, catalog, schema, known):
|
|
257
|
+
self.catalog = (catalog or "").lower() or None
|
|
258
|
+
self.schema = (schema or "").lower() or None
|
|
259
|
+
self.conditions: list[list[JoinPair]] = [] # the pairs of each join condition read, for composite keys
|
|
260
|
+
self.by_suffix: dict[str, set[str]] = {}
|
|
261
|
+
for full in {k.lower() for k in known or ()}:
|
|
262
|
+
parts = full.split(".")
|
|
263
|
+
for i in range(1, len(parts)):
|
|
264
|
+
self.by_suffix.setdefault(".".join(parts[i:]), set()).add(full)
|
|
265
|
+
|
|
266
|
+
def qualify(self, table: exp.Table):
|
|
267
|
+
if not isinstance(table.this, exp.Identifier):
|
|
268
|
+
return None # a table-valued function
|
|
269
|
+
parts = [p.name.lower() for p in table.parts]
|
|
270
|
+
if len(parts) > 3:
|
|
271
|
+
return None
|
|
272
|
+
if len(parts) == 3:
|
|
273
|
+
return ".".join(parts)
|
|
274
|
+
short = ".".join(parts)
|
|
275
|
+
default = None
|
|
276
|
+
if len(parts) == 2 and self.catalog:
|
|
277
|
+
default = f"{self.catalog}.{short}"
|
|
278
|
+
elif len(parts) == 1 and self.catalog and self.schema:
|
|
279
|
+
default = f"{self.catalog}.{self.schema}.{short}"
|
|
280
|
+
found = self.by_suffix.get(short, set())
|
|
281
|
+
if default in found:
|
|
282
|
+
return default
|
|
283
|
+
if len(found) == 1:
|
|
284
|
+
return next(iter(found))
|
|
285
|
+
return default
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _FlatScope:
|
|
289
|
+
"""The few Scope attributes the resolver reads, for statements sqlglot builds no scope for (MERGE)."""
|
|
290
|
+
|
|
291
|
+
def __init__(self, sources):
|
|
292
|
+
self.sources = sources
|
|
293
|
+
self.parent = None
|
|
294
|
+
self.scope_type = ScopeType.ROOT
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
_MAX_DEPTH = 12
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _strip(node):
|
|
301
|
+
"""The expression under any parentheses and casts, which do not change which column is meant."""
|
|
302
|
+
while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
|
|
303
|
+
node = node.this
|
|
304
|
+
return node
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _unwrap_query(node):
|
|
308
|
+
while isinstance(node, exp.Subquery):
|
|
309
|
+
node = node.this
|
|
310
|
+
return node
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _is_column(node) -> bool:
|
|
314
|
+
return isinstance(node, exp.Column) and not isinstance(node.this, exp.Star)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _sources_of(scope) -> dict:
|
|
318
|
+
"""The tables and derived tables a scope's own FROM and JOINs name. sqlglot also lists the CTEs visible to it."""
|
|
319
|
+
sources = {str(k).lower(): v for k, v in scope.sources.items()}
|
|
320
|
+
query = getattr(scope, "expression", None)
|
|
321
|
+
if not isinstance(query, exp.Select):
|
|
322
|
+
return sources
|
|
323
|
+
from_ = query.args.get("from_")
|
|
324
|
+
named = {n.alias_or_name.lower() for n in ([from_.this] if from_ is not None else []) + (query.args.get("joins") or [])}
|
|
325
|
+
return {k: v for k, v in sources.items() if k in named}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _trace(source, name: str, names: _Names, depth: int = 0):
|
|
329
|
+
"""(base table, column) a column of one source comes from, looking through CTEs and subqueries. None if unclear."""
|
|
330
|
+
if depth > _MAX_DEPTH:
|
|
331
|
+
return None
|
|
332
|
+
if isinstance(source, exp.Table):
|
|
333
|
+
full = names.qualify(source)
|
|
334
|
+
return (full, name) if full else None
|
|
335
|
+
query = _unwrap_query(getattr(source, "expression", None))
|
|
336
|
+
if not isinstance(query, exp.Select):
|
|
337
|
+
return None # a UNION, VALUES or function: the column has no single origin
|
|
338
|
+
inner = _sources_of(source)
|
|
339
|
+
stars = []
|
|
340
|
+
for projection in query.expressions:
|
|
341
|
+
value = projection.this if isinstance(projection, exp.Alias) else projection
|
|
342
|
+
if projection.alias_or_name.lower() == name and not isinstance(value, exp.Star):
|
|
343
|
+
value = _strip(value)
|
|
344
|
+
if not _is_column(value):
|
|
345
|
+
return None # computed: not a plain column
|
|
346
|
+
found = _resolve(value, source, names, depth + 1)
|
|
347
|
+
return found[2:] if found else None
|
|
348
|
+
if isinstance(value, exp.Star) or (isinstance(value, exp.Column) and isinstance(value.this, exp.Star)):
|
|
349
|
+
stars.append(value)
|
|
350
|
+
if len(stars) != 1:
|
|
351
|
+
return None
|
|
352
|
+
star = stars[0]
|
|
353
|
+
if isinstance(star, exp.Column): # alias.*
|
|
354
|
+
target = inner.get(star.table.lower())
|
|
355
|
+
else:
|
|
356
|
+
target = next(iter(inner.values())) if len(inner) == 1 else None
|
|
357
|
+
return _trace(target, name, names, depth + 1) if target is not None else None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _resolve(col: exp.Column, scope, names: _Names, depth: int = 0):
|
|
361
|
+
"""(scope id, source key, base table, column) for a column reference, or None when it cannot be pinned down."""
|
|
362
|
+
name = col.name.lower()
|
|
363
|
+
qualifier = col.table.lower()
|
|
364
|
+
current = scope
|
|
365
|
+
key = None
|
|
366
|
+
while current is not None:
|
|
367
|
+
sources = _sources_of(current)
|
|
368
|
+
if qualifier:
|
|
369
|
+
if qualifier in sources:
|
|
370
|
+
key = qualifier
|
|
371
|
+
break
|
|
372
|
+
elif len(sources) == 1:
|
|
373
|
+
key = next(iter(sources))
|
|
374
|
+
break
|
|
375
|
+
else:
|
|
376
|
+
return None # an unqualified column with several candidate tables, and no schema to decide
|
|
377
|
+
# A correlated subquery may name a table of the query around it.
|
|
378
|
+
current = getattr(current, "parent", None) if current.scope_type == ScopeType.SUBQUERY else None
|
|
379
|
+
if current is None or key is None:
|
|
380
|
+
return None
|
|
381
|
+
traced = _trace(sources[key], name, names, depth)
|
|
382
|
+
return (id(current), key, *traced) if traced else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _conjuncts(cond):
|
|
386
|
+
"""The equalities that must all hold: the AND-ed parts of a condition. OR branches are left out."""
|
|
387
|
+
if isinstance(cond, exp.Paren):
|
|
388
|
+
yield from _conjuncts(cond.this)
|
|
389
|
+
elif isinstance(cond, exp.And):
|
|
390
|
+
yield from _conjuncts(cond.left)
|
|
391
|
+
yield from _conjuncts(cond.right)
|
|
392
|
+
elif isinstance(cond, (exp.EQ, exp.NullSafeEQ)):
|
|
393
|
+
yield cond
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _equalities(cond, scope, names: _Names, join_type: str):
|
|
397
|
+
got: list[JoinPair] = []
|
|
398
|
+
for eq in _conjuncts(cond):
|
|
399
|
+
left, right = _strip(eq.left), _strip(eq.right)
|
|
400
|
+
if not (_is_column(left) and _is_column(right)):
|
|
401
|
+
continue
|
|
402
|
+
a, b = _resolve(left, scope, names), _resolve(right, scope, names)
|
|
403
|
+
if a and b and a[:2] != b[:2] and a[2:] != b[2:]:
|
|
404
|
+
got.append(_pair(a[2], a[3], b[2], b[3], join_type))
|
|
405
|
+
yield got[-1]
|
|
406
|
+
names.conditions.append(got)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _pair(t1, c1, t2, c2, join_type) -> JoinPair:
|
|
410
|
+
if (t2, c2) < (t1, c1):
|
|
411
|
+
t1, c1, t2, c2 = t2, c2, t1, c1
|
|
412
|
+
return JoinPair(t1, c1, t2, c2, join_type)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _join_type(join: exp.Join) -> str:
|
|
416
|
+
kind, side = (join.kind or "").lower(), (join.side or "").lower()
|
|
417
|
+
if kind in ("semi", "anti"):
|
|
418
|
+
return kind
|
|
419
|
+
return side if side in ("left", "right", "full") else "inner"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _select_pairs(scope, names: _Names):
|
|
423
|
+
select = scope.expression
|
|
424
|
+
if not isinstance(select, exp.Select):
|
|
425
|
+
return
|
|
426
|
+
sources = _sources_of(scope)
|
|
427
|
+
from_ = select.args.get("from_")
|
|
428
|
+
preceding = [from_.this.alias_or_name.lower()] if from_ is not None else []
|
|
429
|
+
implicit = False
|
|
430
|
+
for join in select.args.get("joins") or []:
|
|
431
|
+
key = join.alias_or_name.lower()
|
|
432
|
+
kind = _join_type(join)
|
|
433
|
+
if join.args.get("on") is not None:
|
|
434
|
+
yield from _equalities(join.args["on"], scope, names, kind)
|
|
435
|
+
elif join.args.get("using"):
|
|
436
|
+
got: list[JoinPair] = []
|
|
437
|
+
for ident in join.args["using"]:
|
|
438
|
+
name = ident.name.lower()
|
|
439
|
+
one_left = len(preceding) == 1 and preceding[0] in sources
|
|
440
|
+
left = _trace(sources[preceding[0]], name, names) if one_left else None
|
|
441
|
+
right = _trace(sources[key], name, names) if key in sources else None
|
|
442
|
+
if left and right and left != right:
|
|
443
|
+
got.append(_pair(*left, *right, kind))
|
|
444
|
+
yield got[-1]
|
|
445
|
+
names.conditions.append(got)
|
|
446
|
+
elif not join.args.get("method"):
|
|
447
|
+
implicit = True # a comma or CROSS JOIN: its condition, if any, is in WHERE
|
|
448
|
+
preceding.append(key)
|
|
449
|
+
where = select.args.get("where")
|
|
450
|
+
if where is not None and implicit:
|
|
451
|
+
yield from _equalities(where.this, scope, names, "implicit")
|
|
452
|
+
elif where is not None and scope.scope_type == ScopeType.SUBQUERY:
|
|
453
|
+
yield from _equalities(where.this, scope, names, "correlated")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _merge_pairs(merge: exp.Merge, names: _Names):
|
|
457
|
+
on = merge.args.get("on")
|
|
458
|
+
if on is None:
|
|
459
|
+
return
|
|
460
|
+
sources = {}
|
|
461
|
+
for node in (merge.this, merge.args.get("using")):
|
|
462
|
+
if isinstance(node, exp.Table):
|
|
463
|
+
sources[node.alias_or_name.lower()] = node
|
|
464
|
+
elif isinstance(node, exp.Subquery):
|
|
465
|
+
inner = build_scope(node.this)
|
|
466
|
+
if inner is not None:
|
|
467
|
+
sources[node.alias_or_name.lower()] = inner
|
|
468
|
+
yield from (p for s in traverse_scope(node.this) for p in _select_pairs(s, names))
|
|
469
|
+
yield from _equalities(on, _FlatScope(sources), names, "inner")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def join_pairs(sql: str, default_catalog: str | None = None, default_schema: str | None = None,
|
|
473
|
+
known: Iterable[str] | None = None) -> list[JoinPair]:
|
|
474
|
+
"""Column pairs a statement joins on, resolved through aliases, CTEs and subqueries to base tables (see join_analysis)."""
|
|
475
|
+
return join_analysis(sql, default_catalog, default_schema, known)[0]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def join_analysis(sql: str, default_catalog: str | None = None, default_schema: str | None = None,
|
|
479
|
+
known: Iterable[str] | None = None) -> tuple[list[JoinPair], list[tuple[tuple, ...]]]:
|
|
480
|
+
"""The join pairs of a statement and its composite keys: `(pairs, composites)`.
|
|
481
|
+
|
|
482
|
+
A composite key is two or more different column pairs between the same two tables in one join condition
|
|
483
|
+
(`ON a.x = b.x AND a.y = b.y`, or `USING (x, y)`). Each is a sorted tuple of pair keys `(table, column, table,
|
|
484
|
+
column)`, and the same key found twice in a statement is listed once.
|
|
485
|
+
|
|
486
|
+
The pairs are the column pairs a statement joins on, resolved through aliases, CTEs and subqueries to base tables.
|
|
487
|
+
|
|
488
|
+
Reads ON equalities (composite AND keys too), USING, comma and CROSS joins whose WHERE equates columns,
|
|
489
|
+
MERGE ... ON, and equalities that correlate a subquery with its outer query. Anything that cannot be
|
|
490
|
+
pinned to one base column is dropped, never guessed: OR conditions, expressions, an unqualified column
|
|
491
|
+
when several tables are in scope, UNION branches, a USING column when more than one table is on its left.
|
|
492
|
+
Short table names are qualified from the defaults, or from `known` (full names the statement is known to
|
|
493
|
+
read) when exactly one of them fits. Names and columns come back lowercase and each pair has one spelling,
|
|
494
|
+
sorted, so the same join written either way round is equal. Raises ValueError when the SQL does not parse.
|
|
495
|
+
"""
|
|
496
|
+
names = _Names(default_catalog, default_schema, known)
|
|
497
|
+
try:
|
|
498
|
+
trees = sqlglot.parse(sql, read=_DIALECT)
|
|
499
|
+
found: dict[tuple, JoinPair] = {}
|
|
500
|
+
for tree in trees:
|
|
501
|
+
if tree is None:
|
|
502
|
+
continue
|
|
503
|
+
if isinstance(tree, exp.Merge):
|
|
504
|
+
pairs = _merge_pairs(tree, names)
|
|
505
|
+
else:
|
|
506
|
+
pairs = (p for scope in traverse_scope(tree) for p in _select_pairs(scope, names))
|
|
507
|
+
for p in pairs:
|
|
508
|
+
found.setdefault(p[:4], p)
|
|
509
|
+
return list(found.values()), _composites(names.conditions)
|
|
510
|
+
except sqlglot.errors.SqlglotError as e:
|
|
511
|
+
raise ValueError(f"cannot parse SQL: {e}") from e
|
|
512
|
+
except RecursionError as e:
|
|
513
|
+
raise ValueError("cannot parse SQL: nested too deeply") from e
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _composites(conditions) -> list[tuple[tuple, ...]]:
|
|
517
|
+
"""Sorted pair-key tuples for each set of 2+ different pairs between one two tables inside one condition."""
|
|
518
|
+
out = set()
|
|
519
|
+
for cond in conditions:
|
|
520
|
+
by_tables: dict[tuple, set[tuple]] = {}
|
|
521
|
+
for p in cond:
|
|
522
|
+
if p.left_table != p.right_table:
|
|
523
|
+
by_tables.setdefault((p.left_table, p.right_table), set()).add(tuple(p[:4]))
|
|
524
|
+
out.update(tuple(sorted(keys)) for keys in by_tables.values() if len(keys) >= 2)
|
|
525
|
+
return sorted(out)
|