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.
@@ -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)