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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|