ripple-sql 0.1.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.
Files changed (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,265 @@
1
+ """Top-level extraction pipeline.
2
+
3
+ extract_column_lineage_with_ctes wraps the statement extractor with CTE
4
+ mapping collection, primary-source detection, dialect fallback, and warning
5
+ aggregation. extract_lineage_complete is the unified API: C_con
6
+ (contributing columns) plus C_ref (filter columns and join keys).
7
+ """
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from ripple.engine.cte_tracing import extract_cte_column_lineage
14
+ from ripple.engine.macro_source import extract_primary_source_from_macro
15
+ from ripple.engine.statement import extract_column_lineage_fast
16
+ from ripple.engine.types import (
17
+ META_CTE_COLLISIONS,
18
+ META_EXTRACTION_ERROR,
19
+ META_EXTRACTION_ERROR_TYPE,
20
+ META_FALLBACK_DIALECT,
21
+ META_PREFIX,
22
+ META_QUALIFIED_VIA_SCHEMA,
23
+ META_WARNINGS,
24
+ UnifiedLineageResult,
25
+ WarehouseColumns,
26
+ )
27
+ from ripple.engine.validation import get_trust_level
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def extract_column_lineage_with_ctes(
33
+ raw_sql: str,
34
+ dialect: str = "snowflake",
35
+ cte_mappings_func: Callable[[str], dict] | None = None,
36
+ join_mappings_func: Callable[[str], dict] | None = None,
37
+ clean_jinja_func: Callable[[str], str] | None = None,
38
+ clean_jinja_minimal_func: Callable[[str], str] | None = None,
39
+ warehouse_columns: WarehouseColumns | None = None,
40
+ real_tables: set[str] | None = None,
41
+ self_names: set[str] | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Extract column lineage AND CTE/alias->ref mappings from SQL.
44
+
45
+ Returns:
46
+ {
47
+ "column_lineage": {column_name: [sources]},
48
+ "cte_mappings": {cte_name: {type, ref/source/table}},
49
+ "cte_column_lineage": {cte_name: {column: sources}},
50
+ "primary_source": {type, source, table} (optional),
51
+ "warnings": [...] (if any issues detected),
52
+ "qualified_via_schema": bool (if warehouse schema used),
53
+ }
54
+
55
+ Args:
56
+ raw_sql: Raw SQL (possibly with Jinja templates)
57
+ dialect: SQL dialect (snowflake, bigquery, databricks, etc.)
58
+ cte_mappings_func: Function to extract CTE mappings (injected)
59
+ join_mappings_func: Function to extract JOIN mappings (injected)
60
+ clean_jinja_func: Function to clean Jinja from SQL (injected)
61
+ clean_jinja_minimal_func: Function for minimal Jinja cleaning (injected)
62
+ warehouse_columns: Schema info from warehouse {table: {column: data_type}}
63
+ real_tables: Set of real table names (for CTE collision detection)
64
+ """
65
+ # Use injected functions or return empty dicts
66
+ if cte_mappings_func is None:
67
+
68
+ def cte_mappings_func(sql):
69
+ return {}
70
+
71
+ if join_mappings_func is None:
72
+
73
+ def join_mappings_func(sql):
74
+ return {}
75
+
76
+ cte_mappings = cte_mappings_func(raw_sql)
77
+ join_mappings = join_mappings_func(raw_sql)
78
+ cte_column_lineage = extract_cte_column_lineage(
79
+ raw_sql,
80
+ dialect=dialect,
81
+ clean_jinja_func=clean_jinja_func,
82
+ real_tables=real_tables,
83
+ self_names=self_names,
84
+ )
85
+
86
+ # Collect warnings from CTE extraction
87
+ all_warnings: list[dict[str, Any]] = []
88
+ if META_CTE_COLLISIONS in cte_column_lineage:
89
+ collisions = cte_column_lineage.pop(META_CTE_COLLISIONS)
90
+ if isinstance(collisions, list):
91
+ all_warnings.extend(collisions)
92
+
93
+ # Merge: CTE mappings take precedence over join mappings
94
+ combined_mappings = {**join_mappings, **cte_mappings}
95
+
96
+ primary_source = extract_primary_source_from_macro(raw_sql)
97
+
98
+ column_lineage = extract_column_lineage_fast(
99
+ raw_sql,
100
+ dialect=dialect,
101
+ clean_jinja_func=clean_jinja_func,
102
+ clean_jinja_minimal_func=clean_jinja_minimal_func,
103
+ warehouse_columns=warehouse_columns,
104
+ real_tables=real_tables,
105
+ self_names=self_names,
106
+ )
107
+
108
+ # Collect warnings from column lineage extraction
109
+ if META_WARNINGS in column_lineage:
110
+ warnings = column_lineage.pop(META_WARNINGS)
111
+ if isinstance(warnings, list):
112
+ all_warnings.extend(warnings)
113
+
114
+ # Track if schema was used for qualification
115
+ qualified_via_schema = bool(column_lineage.pop(META_QUALIFIED_VIA_SCHEMA, False))
116
+
117
+ has_error = META_EXTRACTION_ERROR in column_lineage
118
+ has_columns = any(k for k in column_lineage if not k.startswith(META_PREFIX))
119
+
120
+ if has_error or not has_columns:
121
+ fallback_dialects = ["snowflake", "postgres", "bigquery", "duckdb"]
122
+ last_error = column_lineage.get(META_EXTRACTION_ERROR) if has_error else None
123
+ last_error_type = column_lineage.get(META_EXTRACTION_ERROR_TYPE) if has_error else None
124
+
125
+ for fallback in fallback_dialects:
126
+ if fallback != dialect:
127
+ fallback_result = extract_column_lineage_fast(
128
+ raw_sql,
129
+ dialect=fallback,
130
+ clean_jinja_func=clean_jinja_func,
131
+ clean_jinja_minimal_func=clean_jinja_minimal_func,
132
+ warehouse_columns=warehouse_columns,
133
+ real_tables=real_tables,
134
+ )
135
+ fallback_has_error = META_EXTRACTION_ERROR in fallback_result
136
+ fallback_has_columns = any(
137
+ k for k in fallback_result if not k.startswith(META_PREFIX)
138
+ )
139
+
140
+ if fallback_has_columns and not fallback_has_error:
141
+ # Degrade confidence for fallback-parsed lineage
142
+ for col_key, col_sources in fallback_result.items():
143
+ if col_key.startswith(META_PREFIX):
144
+ continue
145
+ if isinstance(col_sources, list):
146
+ for src in col_sources:
147
+ original = src.get("confidence", 1.0)
148
+ src["confidence"] = round(original * 0.7, 3)
149
+ src["trust_level"] = get_trust_level(src["confidence"])
150
+ fallback_result[META_FALLBACK_DIALECT] = fallback
151
+ column_lineage = fallback_result
152
+ logger.debug(f"Column lineage extracted with fallback dialect: {fallback}")
153
+ break
154
+ elif fallback_has_error:
155
+ last_error = fallback_result.get(META_EXTRACTION_ERROR)
156
+ last_error_type = fallback_result.get(META_EXTRACTION_ERROR_TYPE)
157
+ else:
158
+ if last_error:
159
+ column_lineage = {
160
+ META_EXTRACTION_ERROR: last_error,
161
+ META_EXTRACTION_ERROR_TYPE: last_error_type,
162
+ }
163
+
164
+ fallback_dialect = column_lineage.pop(META_FALLBACK_DIALECT, None)
165
+
166
+ result = {
167
+ "column_lineage": column_lineage,
168
+ "cte_mappings": combined_mappings,
169
+ "cte_column_lineage": cte_column_lineage,
170
+ }
171
+
172
+ if fallback_dialect:
173
+ result["fallback_dialect"] = fallback_dialect
174
+
175
+ if primary_source:
176
+ result["primary_source"] = primary_source
177
+
178
+ if all_warnings:
179
+ result["warnings"] = all_warnings
180
+
181
+ if qualified_via_schema:
182
+ result["qualified_via_schema"] = True
183
+
184
+ return result
185
+
186
+
187
+ def extract_lineage_complete(
188
+ sql: str,
189
+ dialect: str = "snowflake",
190
+ warehouse_columns: WarehouseColumns | None = None,
191
+ real_tables: set[str] | None = None,
192
+ clean_jinja_func: Callable[[str], str] | None = None,
193
+ function_relations: set[str] | None = None,
194
+ self_names: set[str] | None = None,
195
+ ) -> UnifiedLineageResult:
196
+ """Extract complete lineage: contributing + referenced columns.
197
+
198
+ Returns both:
199
+ - C_con: Columns that contribute to output values
200
+ - C_ref: Columns referenced in WHERE/JOIN (affect row selection)
201
+
202
+ This follows the LineageX paper's model, giving complete visibility
203
+ into which columns affect the query results.
204
+
205
+ Args:
206
+ sql: SQL query to analyze
207
+ dialect: SQL dialect
208
+ warehouse_columns: Schema for disambiguation
209
+ real_tables: Real table names for CTE collision detection
210
+ clean_jinja_func: Optional Jinja cleaner
211
+
212
+ Returns:
213
+ UnifiedLineageResult with contributing, filter_columns, join_keys
214
+ """
215
+ from ripple.engine.dependencies import extract_column_dependencies
216
+
217
+ # Clean SQL if cleaner provided
218
+ cleaned_sql = clean_jinja_func(sql) if clean_jinja_func else sql
219
+
220
+ contributing = extract_column_lineage_fast(
221
+ sql,
222
+ dialect=dialect,
223
+ warehouse_columns=warehouse_columns,
224
+ real_tables=real_tables,
225
+ clean_jinja_func=clean_jinja_func,
226
+ function_relations=function_relations,
227
+ self_names=self_names,
228
+ )
229
+
230
+ dependencies = extract_column_dependencies(
231
+ cleaned_sql, dialect, warehouse_columns=warehouse_columns
232
+ )
233
+
234
+ # Combine warnings
235
+ raw_warnings = contributing.pop(META_WARNINGS, [])
236
+ all_warnings: list[dict[str, Any]] = raw_warnings if isinstance(raw_warnings, list) else []
237
+ all_warnings.extend([{"warning": w} for w in dependencies.warnings])
238
+
239
+ # A statement the extractor couldn't analyze must say so here, not
240
+ # vanish when the underscore keys are stripped (MAP/REDUCE script
241
+ # transforms, unparseable dialect corners, future failures).
242
+ if META_EXTRACTION_ERROR in contributing:
243
+ all_warnings.append(
244
+ {
245
+ "warning_type": "extraction_error",
246
+ "trust_level": "review_required",
247
+ "message": str(contributing.get(META_EXTRACTION_ERROR)),
248
+ "error_type": str(contributing.get(META_EXTRACTION_ERROR_TYPE, "Unknown")),
249
+ }
250
+ )
251
+
252
+ qualified = bool(contributing.pop(META_QUALIFIED_VIA_SCHEMA, False))
253
+
254
+ clean_contributing: dict[str, list[dict[str, Any]]] = {
255
+ k: v for k, v in contributing.items() if not k.startswith(META_PREFIX)
256
+ }
257
+
258
+ return UnifiedLineageResult(
259
+ contributing=clean_contributing,
260
+ filter_columns=dependencies.filter_columns,
261
+ join_keys=dependencies.join_keys,
262
+ window_keys=dependencies.window_keys,
263
+ warnings=all_warnings,
264
+ qualified_via_schema=qualified,
265
+ )
@@ -0,0 +1,174 @@
1
+ """Pre-parse rewrites for constructs sqlglot can't carry through.
2
+
3
+ IDENTIFIER('literal') (Databricks/Spark parameterized identifiers) either
4
+ fails to parse or parses to a nameless node that would emit lineage to an
5
+ empty table. A single string-literal argument is statically resolvable, so
6
+ rewrite it to the identifier before parsing and record that we did.
7
+ Non-literal arguments (:params, || concat) are left alone; the extractor
8
+ flags them as unresolvable instead of guessing.
9
+
10
+ Single-brace str.format placeholders in relation position ({hscic}.t) are
11
+ the same family: statically rewritable before parsing, with the braced
12
+ spelling kept verbatim in the emitted name.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Callable
19
+
20
+ _IDENTIFIER_LITERAL = re.compile(r"\bIDENTIFIER\s*\(\s*'([A-Za-z0-9_.`\"$]+)'\s*\)", re.IGNORECASE)
21
+
22
+
23
+ def rewrite_identifier_literals(sql: str) -> tuple[str, int]:
24
+ """Replace IDENTIFIER('name') with name. Returns (sql, replacements)."""
25
+ rewritten, count = _IDENTIFIER_LITERAL.subn(r"\1", sql)
26
+ return rewritten, count
27
+
28
+
29
+ _IDENTIFIER_ANY = re.compile(r"\bIDENTIFIER\s*\(", re.IGNORECASE)
30
+
31
+
32
+ def has_unresolved_identifier(sql: str) -> bool:
33
+ """True when an IDENTIFIER(...) call with a non-literal argument remains."""
34
+ return bool(_IDENTIFIER_ANY.search(sql))
35
+
36
+
37
+ # a surviving pure name/attribute chain ({{params.x}}, the double-brace
38
+ # sibling of the single-brace class) is quotable like any placeholder;
39
+ # jinja spans with anything else in them stay protected verbatim
40
+ _JINJA_CHAIN_BODY = r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
41
+ _JINJA_CHAIN = re.compile(rf"\{{\{{{_JINJA_CHAIN_BODY}\}}\}}")
42
+
43
+ # Dots INSIDE a quoted chain still split the identifier into table parts in
44
+ # BigQuery; encoded before quoting, restored to "." at the identifier
45
+ # accessors (column_ref.column_parts / qualified_table_name and the CTE map
46
+ # keys) so the emitted spelling is byte-for-byte as written. A long ASCII
47
+ # token, not a private-use codepoint: a real identifier containing U+E000
48
+ # must round-trip untouched (cycle-12 review, F3).
49
+ JINJA_DOT_SENTINEL = "__ripple_jinja_dot__"
50
+
51
+ # A raw chain already containing the token must not read as an encoded dot:
52
+ # the accessors would keep the qualifier and mutate its spelling (cycle-13
53
+ # review, F1). Encoding bumps any pre-existing __ripple_jinja_dot[N]__ one
54
+ # level up and restoration bumps it back down, so a bare token in a parsed
55
+ # name always means a dot we encoded and raw spellings round-trip. Applied
56
+ # to gap-text chains only: re-prepared model SQL carries its chains inside
57
+ # quoted spans, which keeps the bump single-shot.
58
+ _SENTINEL_ANY_LEVEL = re.compile(r"__ripple_jinja_dot(\d*)__")
59
+ _SENTINEL_ESCAPED = re.compile(r"__ripple_jinja_dot(\d+)__")
60
+
61
+
62
+ def _encode_chain_dots(chain: str) -> str:
63
+ escaped = _SENTINEL_ANY_LEVEL.sub(
64
+ lambda m: f"__ripple_jinja_dot{int(m.group(1) or -1) + 1}__", chain
65
+ )
66
+ return escaped.replace(".", JINJA_DOT_SENTINEL)
67
+
68
+
69
+ _PROTECTED = re.compile(
70
+ rf"""'(?:\\.|''|[^'\\])*'
71
+ | "(?:\\.|""|[^"\\])*"
72
+ | `[^`]*`
73
+ | \[(?:\]\]|[^\]])*\]
74
+ | \{{\{{(?!{_JINJA_CHAIN_BODY}\}}\}}).*?\}}\}}
75
+ | \{{%.*?%\}}
76
+ | \{{\#.*?\#\}}
77
+ | --[^\n]*
78
+ | /\*.*?\*/
79
+ """,
80
+ re.VERBOSE | re.DOTALL,
81
+ )
82
+
83
+ # a hyphen joins atoms only when tightly adjacent, so BigQuery's
84
+ # my-{env}.dataset.t quotes as one segment while spaced `a - {x}` stays two
85
+ _ENCODED_CHAIN = rf"\{{\{{[A-Za-z_](?:[A-Za-z0-9_]|{JINJA_DOT_SENTINEL})*\}}\}}"
86
+ _PLACEHOLDER_ATOM = rf"(?:{_ENCODED_CHAIN}|\{{[A-Za-z_][A-Za-z0-9_]*\}}|[A-Za-z0-9_])+"
87
+ _PLACEHOLDER_SEGMENT = re.compile(rf"{_PLACEHOLDER_ATOM}(?:-{_PLACEHOLDER_ATOM})*")
88
+
89
+
90
+ def restore_jinja_dots(name: str) -> str:
91
+ """Undo the in-chain dot encoding; identity for ordinary names."""
92
+ if "__ripple_jinja_dot" not in name:
93
+ return name
94
+ name = name.replace(JINJA_DOT_SENTINEL, ".")
95
+ return _SENTINEL_ESCAPED.sub(
96
+ lambda m: f"__ripple_jinja_dot{int(m.group(1)) - 1 if m.group(1) != '0' else ''}__",
97
+ name,
98
+ )
99
+
100
+
101
+ def _identifier_quotes(dialect: str) -> tuple[str, str]:
102
+ try:
103
+ from sqlglot.dialects.dialect import Dialect
104
+
105
+ d = Dialect.get_or_raise(dialect or None)
106
+ return d.IDENTIFIER_START, d.IDENTIFIER_END
107
+ except Exception:
108
+ return '"', '"'
109
+
110
+
111
+ def rewrite_brace_placeholders(sql: str, dialect: str = "") -> tuple[str, int]:
112
+ """Quote name segments containing {placeholder} so they parse as identifiers.
113
+
114
+ Python str.format placeholders like {hscic}.practice_statistics fail to
115
+ parse and zero the whole statement. Each dotted-name segment holding a
116
+ single-brace {ident} placeholder is wrapped in the dialect's identifier
117
+ quote, so sqlglot reads a plain identifier whose name keeps the braces
118
+ exactly as written. Ground truth cites these relations with the braces,
119
+ so the spelling must survive into the emitted table name. String
120
+ literals, jinja double braces, and already-quoted content are left
121
+ alone. Returns (sql, segments quoted).
122
+ """
123
+ if "{" not in sql:
124
+ return sql, 0
125
+ start_q, end_q = _identifier_quotes(dialect)
126
+ # BigQuery splits a backticked name on its dots, shredding a jinja
127
+ # chain across catalog/db/table ("destination_dataset_project_id}}"
128
+ # became a relation part, bitcoin_etl round 12); encoding in-chain
129
+ # dots inside backticks keeps the split on real path separators
130
+ encode_quoted = (dialect or "").lower() == "bigquery"
131
+ count = 0
132
+
133
+ def quote_segment(m: re.Match[str]) -> str:
134
+ nonlocal count
135
+ seg = m.group(0)
136
+ if "{" not in seg:
137
+ return seg
138
+ count += 1
139
+ return f"{start_q}{seg}{end_q}"
140
+
141
+ def rewrite_gap(text: str) -> str:
142
+ encoded = _JINJA_CHAIN.sub(lambda m: _encode_chain_dots(m.group(0)), text)
143
+ return _PLACEHOLDER_SEGMENT.sub(quote_segment, encoded)
144
+
145
+ out: list[str] = []
146
+ pos = 0
147
+ for m in _PROTECTED.finditer(sql):
148
+ out.append(rewrite_gap(sql[pos : m.start()]))
149
+ span = m.group(0)
150
+ if encode_quoted and span.startswith("`"):
151
+ span = _JINJA_CHAIN.sub(lambda c: c.group(0).replace(".", JINJA_DOT_SENTINEL), span)
152
+ out.append(span)
153
+ pos = m.end()
154
+ out.append(rewrite_gap(sql[pos:]))
155
+ return "".join(out), count
156
+
157
+
158
+ def prepare_sql_for_parse(
159
+ sql: str,
160
+ dialect: str = "",
161
+ clean_jinja_func: Callable[[str], str] | None = None,
162
+ ) -> tuple[str, int]:
163
+ """The one pre-parse pipeline for every site that parses user SQL.
164
+
165
+ Jinja cleaning runs first, then the single-brace rewrite: rewriting
166
+ first quotes across a rendered boundary and corrupts adjacent
167
+ spellings like {prefix}_{{suffix}} (cycle-10 review, F9). Any
168
+ parse of user SQL that skips this drifts from the lineage parse and
169
+ loses edges the way graph._alias_map did (F8). Returns
170
+ (sql, brace segments quoted).
171
+ """
172
+ if clean_jinja_func is not None:
173
+ sql = clean_jinja_func(sql)
174
+ return rewrite_brace_placeholders(sql, dialect)
@@ -0,0 +1,21 @@
1
+ """Crash-safe SQL generation for user-derived ASTs.
2
+
3
+ sqlglot's generator can raise on nodes its own parser produced: T-SQL
4
+ `SET @x -= 1` parses to an empty Neg() that neg_sql cannot render
5
+ (IndexError), and one such statement crashed a whole project load
6
+ (fhir_server, holdout round 11). Every site that re-generates user SQL
7
+ during load or analysis goes through here and degrades to skipping that
8
+ statement, never aborting the file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from sqlglot import exp
14
+
15
+
16
+ def safe_sql(node: exp.Expression, dialect: str | None) -> str | None:
17
+ """node.sql(dialect=...) or None when the generator refuses the node."""
18
+ try:
19
+ return node.sql(dialect=dialect)
20
+ except Exception:
21
+ return None
@@ -0,0 +1,151 @@
1
+ """Schema-aware column qualification using warehouse metadata.
2
+
3
+ This module provides disambiguation of unqualified column references
4
+ using actual warehouse schema information. This is key for achieving
5
+ high-confidence lineage: it resolves ambiguity instead of only flagging it.
6
+
7
+ Key Functions:
8
+ - qualify_sql_with_schema: Qualify SQL AST using warehouse schema
9
+ - resolve_column_table: Find which table a column belongs to
10
+ - build_sqlglot_schema: Convert warehouse columns to SQLGlot format
11
+
12
+ Example:
13
+ Given SQL: SELECT col FROM a, b
14
+ And warehouse schema showing 'col' only exists in table 'a'
15
+ Result: SELECT a.col FROM a, b (ambiguity resolved)
16
+ """
17
+
18
+ import logging
19
+ from typing import TYPE_CHECKING
20
+
21
+ from ripple.engine.types import WarehouseColumns
22
+
23
+ if TYPE_CHECKING:
24
+ from sqlglot import exp
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def build_sqlglot_schema(
30
+ warehouse_columns: WarehouseColumns,
31
+ ) -> dict[str, dict[str, str]]:
32
+ """Convert warehouse columns to SQLGlot schema format.
33
+
34
+ SQLGlot's qualify() expects a schema dict in the format:
35
+ {table_name: {column_name: data_type}}
36
+
37
+ Args:
38
+ warehouse_columns: Dict of {table: {column: data_type}}
39
+
40
+ Returns:
41
+ Schema dict compatible with SQLGlot's qualify()
42
+ """
43
+ # SQLGlot schema format is same as our WarehouseColumns format
44
+ return warehouse_columns
45
+
46
+
47
+ def resolve_column_table(
48
+ column: str,
49
+ candidate_tables: list[str],
50
+ warehouse_columns: WarehouseColumns,
51
+ ) -> tuple[str | None, bool]:
52
+ """Resolve which table a column belongs to using warehouse schema.
53
+
54
+ This is the key function for disambiguating unqualified columns.
55
+ Returns the table name if the column exists in exactly one table,
56
+ otherwise returns None.
57
+
58
+ Args:
59
+ column: Column name to resolve (case-insensitive)
60
+ candidate_tables: List of possible source tables
61
+ warehouse_columns: Schema info from warehouse
62
+
63
+ Returns:
64
+ Tuple of (resolved_table_name, was_resolved)
65
+ - (table_name, True) if column exists in exactly one table
66
+ - (None, False) if column doesn't exist in any known table
67
+ - (None, False) if column exists in multiple tables (still ambiguous)
68
+ """
69
+ column_lower = column.lower()
70
+ matching_tables = []
71
+
72
+ for table in candidate_tables:
73
+ table_lower = table.lower()
74
+ # Try both exact table name and lowercase version
75
+ if table in warehouse_columns:
76
+ cols = warehouse_columns[table]
77
+ elif table_lower in warehouse_columns:
78
+ cols = warehouse_columns[table_lower]
79
+ else:
80
+ continue
81
+
82
+ col_names_lower = {c.lower() for c in cols}
83
+ if column_lower in col_names_lower:
84
+ matching_tables.append(table)
85
+
86
+ if len(matching_tables) == 1:
87
+ return matching_tables[0], True
88
+ return None, False
89
+
90
+
91
+ def qualify_sql_with_schema(
92
+ sql: str,
93
+ dialect: str,
94
+ warehouse_columns: WarehouseColumns,
95
+ ) -> tuple["exp.Expression | None", bool]:
96
+ """Qualify unqualified columns in SQL using warehouse schema.
97
+
98
+ Uses SQLGlot's optimizer.qualify to add table qualifiers to columns.
99
+ This resolves ambiguous column references like SELECT col FROM a, b
100
+ into SELECT a.col FROM a, b when 'col' only exists in table 'a'.
101
+
102
+ Returns the qualified AST directly so the caller doesn't re-parse.
103
+ The caller should use this AST instead of re-parsing a SQL string.
104
+
105
+ Args:
106
+ sql: SQL to qualify
107
+ dialect: SQL dialect
108
+ warehouse_columns: Schema info from warehouse
109
+
110
+ Returns:
111
+ Tuple of (qualified_ast, was_qualified)
112
+ - Returns (AST, True) if qualification succeeded
113
+ - Returns (None, False) if qualification failed
114
+ """
115
+ try:
116
+ import sqlglot
117
+ from sqlglot.optimizer.qualify import qualify
118
+ except Exception as e:
119
+ logger.debug(f"SQLGlot import failed: {e}")
120
+ return None, False
121
+
122
+ schema_dict = build_sqlglot_schema(warehouse_columns)
123
+ try:
124
+ parsed = sqlglot.parse_one(sql, read=dialect)
125
+ qualified = qualify(parsed, schema=schema_dict, dialect=dialect)
126
+ return qualified, True
127
+ except Exception as e:
128
+ logger.debug(f"Strict qualification failed, retrying best-effort: {e}")
129
+ # Partial parent schemas are the norm (star-passthrough parents): qualify
130
+ # what the schema resolves, leave the rest as written instead of throwing
131
+ # away every resolved column with it.
132
+ try:
133
+ parsed = sqlglot.parse_one(sql, read=dialect)
134
+ qualified = qualify(
135
+ parsed,
136
+ schema=schema_dict,
137
+ dialect=dialect,
138
+ validate_qualify_columns=False,
139
+ allow_partial_qualification=True,
140
+ infer_schema=True,
141
+ )
142
+ return qualified, True
143
+ except Exception as e:
144
+ logger.debug(f"SQLGlot qualification failed, using heuristics: {e}")
145
+ return None, False
146
+
147
+
148
+ # Underscore-prefixed aliases
149
+ _build_sqlglot_schema = build_sqlglot_schema
150
+ _resolve_column_table = resolve_column_table
151
+ _qualify_sql_with_schema = qualify_sql_with_schema