Skip to content

Commit 03787df

Browse files
blarghmateyclaude
andauthored
fix(ol_dbt_cli): sql_parser — fix 3 Jinja-collapse defects (trailing-comma CTE, alias loss, subquery FP) (#2454)
* fix(ol_dbt_cli): sql_parser — fix 3 Jinja-collapse defects (trailing-comma CTE, alias loss, subquery FP) Closes the three remaining post-render collapse defects from the 2026-07-13 sql_parser audit (tk-...770aaa). After this, all 659 models parse (was 3 failing) and a full `ol-dbt validate` reports 0 ERRORs (was 1). (1) TRAILING-COMMA CTE MACRO — a CTE-injecting macro followed by a comma (`{{ deduplicate_raw_table(...) }},`) renders to `__macro__,` alone on its line in a WITH list. The bare standalone-line rule only matched a placeholder alone on its line, so the trailing comma left an invalid bare identifier and sqlglot failed with "Could not find outermost SELECT" (3 stg__ models). Add a rule that replaces it with a valid placeholder CTE — with a leading comma, since these injector macros emit leading-comma CTEs and always follow a base CTE that carries no trailing comma. (2) NESTED-ALIAS LOSS — a multi-line `if(cond, a, {{ macro(...) }}) as alias` (int__micromasters__program_certificates) was mis-collapsed: the broken-column repair treated the `if`'s last argument as a split column and deleted the `)`, swallowing the alias into the function and dropping the output column. Gate the collapse on a parse check — it only runs when the SQL is *actually* broken (unbalanced), which a well-formed multi-line function call is not. (3) SUBQUERY FALSE POSITIVE — get_columns_read_from_ref Pass 2 descended into nested subqueries via `clause.find_all(exp.Column)`, so `where x in (select y from other_ref where other_col = ...)` attributed `other_col` to the OUTER ref (the sole ERROR-level FP in a full validate run, in edxorg_to_mitxonline_enrollments). Skip columns whose nearest enclosing Select is not the passthrough source's own Select. 355 CLI tests pass (6 new), pre-commit (ruff+mypy) clean. Full-repo sweep: 0 parse errors across 659 models; full validate: 0 ERRORs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(ol_dbt_cli): gate the broken-column parse-check behind a cheap regex precheck Addresses Copilot review on #2454: _collapse_broken_column_expressions called _parses_cleanly (a full sqlglot parse) for every model, doubling parse work across validate/impact. Add a _BROKEN_COL_RE.search() fast path so the parse gate only runs when the broken-column shape is actually present. Measured over the repo: the expensive parse now runs 15× across 659 models instead of 659×, with 0 parse errors unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e3b4c71 commit 03787df

3 files changed

Lines changed: 159 additions & 9 deletions

File tree

src/ol_dbt_cli/ol_dbt_cli/lib/sql_parser.py

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ def _render_jinja(sql: str) -> tuple[str, list[str], list[str], dict[str, str],
148148
template = env.from_string(sql)
149149
rendered = template.render()
150150

151+
# A block macro that injects a *CTE definition* renders as "__macro__," alone on
152+
# its line inside a WITH list (e.g. `{{ deduplicate_raw_table(...) }},` between two
153+
# named CTEs). The bare standalone-line rule below only matches a placeholder alone
154+
# on its line, so the trailing comma would leave `__macro__,` as an invalid bare
155+
# identifier in the CTE list and sqlglot fails with "Could not find outermost
156+
# SELECT". Replace it with a syntactically valid placeholder CTE first so the
157+
# surrounding CTE list still parses (the placeholder CTE is never referenced).
158+
# The leading comma reconnects to the preceding CTE: these injector macros
159+
# (e.g. deduplicate_raw_table) emit leading-comma CTEs and always follow a
160+
# `source`/base CTE, which itself carries no trailing comma.
161+
rendered = re.sub(
162+
r"(?m)^[^\S\r\n]*(?:__macro__|__undefined__)[^\S\r\n]*,[^\S\r\n]*$",
163+
", __jinja_cte__ as (select 1),",
164+
rendered,
165+
)
151166
# Block-level macro injections (macros that inject CTE SQL fragments) render as
152167
# "__macro__" which occupies a statement-level position where a string literal
153168
# is invalid SQL. Replace standalone __macro__ / __undefined__ lines with SQL comments.
@@ -263,7 +278,25 @@ def _collapse_broken_column_expressions(sql: str) -> str:
263278
Example (after)::
264279
265280
, __jinja__ as course_level
281+
282+
The repair only runs when the SQL is *actually* broken. A well-formed
283+
multi-line function call whose last argument is a macro placeholder —
284+
``if(cond, a, __macro__) as alias`` spanning several lines — parses cleanly,
285+
and the ``, __macro__`` there is a function argument, not a split column. The
286+
``) as alias`` tokens close the ``if(`` and carry the real alias; collapsing
287+
would delete that ``)`` and swallow the alias into the function, dropping the
288+
output column. So we skip the collapse entirely when the input already parses.
266289
"""
290+
# Fast path: the parse gate below is a full sqlglot parse, so only pay for it
291+
# when the broken-column shape (`, __placeholder__ … ) as alias`) is actually
292+
# present. The overwhelming majority of models never match, so they skip both
293+
# the parse and the collapse on a single cheap regex search.
294+
if not _BROKEN_COL_RE.search(sql):
295+
return sql
296+
# The shape is present but may be a well-formed multi-line function call that
297+
# merely looks like it — only repair when the SQL genuinely does not parse.
298+
if _parses_cleanly(sql):
299+
return sql
267300

268301
def _replace(m: re.Match[str]) -> str:
269302
return f"{m.group(1)}__jinja__ as {m.group(2)}\n"
@@ -276,6 +309,15 @@ def _replace(m: re.Match[str]) -> str:
276309
return sql
277310

278311

312+
def _parses_cleanly(sql: str) -> bool:
313+
"""Return True if *sql* parses under Trino without raising a syntax error."""
314+
try:
315+
sqlglot.parse(sql, dialect="trino", error_level=sqlglot.ErrorLevel.RAISE)
316+
except Exception: # noqa: BLE001 — any parse error means "not clean"
317+
return False
318+
return True
319+
320+
279321
def strip_jinja(sql: str) -> JinjaStripResult:
280322
"""Process Jinja in *sql* and return a :class:`JinjaStripResult`.
281323
@@ -582,6 +624,21 @@ def _first_non_cte_select(root: exp.Expression) -> exp.Select | None:
582624
return None
583625

584626

627+
def _nearest_enclosing_select(node: exp.Expression) -> exp.Select | None:
628+
"""Return the closest ``Select`` ancestor of *node*, or None if it has none.
629+
630+
Used to tell whether a column lives directly in a given SELECT or inside one
631+
of its nested subqueries — a column in ``where x in (select y from ...)`` has
632+
the inner query as its nearest enclosing Select, not the outer one.
633+
"""
634+
parent = node.parent
635+
while parent is not None:
636+
if isinstance(parent, exp.Select):
637+
return parent
638+
parent = parent.parent
639+
return None
640+
641+
585642
def _get_star_source(select: exp.Select) -> str:
586643
"""Return the alias-or-name of the relation the outermost SELECT * draws from."""
587644
from_clause = select.args.get("from_")
@@ -1001,14 +1058,21 @@ def get_columns_read_from_ref(
10011058
clauses.append(where)
10021059
for clause in clauses:
10031060
for col_node in clause.find_all(exp.Column):
1004-
if col_node.args.get("table") is None:
1005-
col_name = col_node.name.lower()
1006-
if (
1007-
col_name
1008-
and col_name != "*"
1009-
and col_name not in jinja_placeholders
1010-
and col_name not in passthrough_computed
1011-
):
1012-
cols_read.add(col_name)
1061+
if col_node.args.get("table") is not None:
1062+
continue
1063+
# Prune columns that live inside a nested subquery of this clause
1064+
# (e.g. `where x in (select y from other_ref where z = ...)`): their
1065+
# nearest enclosing SELECT is the inner query, so they belong to a
1066+
# different ref and must not be attributed to this passthrough source.
1067+
if _nearest_enclosing_select(col_node) is not select_node:
1068+
continue
1069+
col_name = col_node.name.lower()
1070+
if (
1071+
col_name
1072+
and col_name != "*"
1073+
and col_name not in jinja_placeholders
1074+
and col_name not in passthrough_computed
1075+
):
1076+
cols_read.add(col_name)
10131077

10141078
return cols_read if cols_read else None

src/ol_dbt_cli/tests/test_impact.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,43 @@ def test_returns_none_when_upstream_not_referenced(self, tmp_path: Path) -> None
258258
result = get_columns_read_from_ref(parsed, "stg_users")
259259
assert result is None
260260

261+
def test_subquery_column_not_attributed_to_outer_ref(self, tmp_path: Path) -> None:
262+
"""A column inside a WHERE subquery over a different ref is not attributed to the outer ref.
263+
264+
`where x in (select y from other_ref where other_col = ...)`: `other_col`
265+
belongs to the inner subquery's ref, not the outer passthrough source, so it
266+
must not be reported as a column read from the outer upstream. Regression for
267+
the sole ERROR-level false positive in a full `ol-dbt validate` run.
268+
"""
269+
from ol_dbt_cli.lib.sql_parser import get_columns_read_from_ref, parse_model_sql
270+
271+
sql = (
272+
"with base as (select * from ref_reversion_version)\n"
273+
"select base.version_id\n"
274+
"from base\n"
275+
"where base.contenttype_id in (\n"
276+
" select ct.id from ref_django_contenttype ct\n"
277+
" where ct.contenttype_full_name = 'x'\n"
278+
")"
279+
)
280+
sql_file = tmp_path / "downstream.sql"
281+
sql_file.write_text(sql)
282+
parsed = parse_model_sql("downstream", sql)
283+
parsed.refs = ["reversion_version", "django_contenttype"]
284+
parsed.ref_placeholder_map = {
285+
"ref_reversion_version": "reversion_version",
286+
"ref_django_contenttype": "django_contenttype",
287+
}
288+
parsed.source_path = sql_file
289+
290+
result = get_columns_read_from_ref(parsed, "reversion_version")
291+
# version_id and contenttype_id are genuine reads from the outer ref…
292+
assert result is not None
293+
assert "version_id" in result
294+
assert "contenttype_id" in result
295+
# …but contenttype_full_name belongs to the inner subquery's ref, not this one.
296+
assert "contenttype_full_name" not in result
297+
261298
def test_detects_aliased_column_read(self, tmp_path: Path) -> None:
262299
"""Column read from upstream but aliased to different name in output is detected."""
263300
from ol_dbt_cli.lib.sql_parser import get_columns_read_from_ref, parse_model_sql

src/ol_dbt_cli/tests/test_sql_parser.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,55 @@ def test_broken_column_regex_stops_at_plain_multiline_alias(self) -> None:
304304
assert "user_hashed_id" in result.output_columns
305305
assert "unique_courses" in result.output_columns
306306

307+
def test_block_macro_with_trailing_comma_becomes_cte(self) -> None:
308+
"""A CTE-injecting macro followed by a comma (`{{ macro(...) }},`) must parse.
309+
310+
The macro renders to `__macro__,` alone on its line inside a WITH list; the
311+
bare-line rule only matches a placeholder alone on its line, so without special
312+
handling the trailing comma leaves an invalid bare identifier and sqlglot fails
313+
with "Could not find outermost SELECT". It is replaced with a placeholder CTE
314+
(leading comma reconnects to the preceding base CTE, which carries no comma).
315+
"""
316+
sql = (
317+
"with\n"
318+
" source as (\n"
319+
" select 1 as id, 2 as user_id\n"
320+
" )\n"
321+
" {{ deduplicate_raw_table(order_by='id', partition_columns='user_id') }},\n"
322+
" cleaned as (\n"
323+
" select id as courserunenrollment_id, user_id\n"
324+
" from most_recent_source\n"
325+
" )\n"
326+
"select * from cleaned"
327+
)
328+
result = parse_model_sql("my_model", sql)
329+
assert result.parse_error is None
330+
assert "courserunenrollment_id" in result.output_columns
331+
assert "user_id" in result.output_columns
332+
333+
def test_multiline_function_over_macro_keeps_alias(self) -> None:
334+
"""`if(cond, a, {{ macro(...) }}) as alias` spanning lines must keep the alias.
335+
336+
Here `, __macro__` is the function's last argument, not a split column, and
337+
`) as alias` closes the `if(` while carrying the real alias. The broken-column
338+
collapse must NOT fire (it would delete the `)` and swallow the alias into the
339+
function), so the output column survives.
340+
"""
341+
sql = (
342+
"with report as (select 1 as program_id, 'x' as program_title)\n"
343+
"select\n"
344+
" report.program_id\n"
345+
" , if(\n"
346+
" report.program_id is not null\n"
347+
" , report.program_title\n"
348+
" , {{ generate_micromasters_program_readable_id('report.program_id', 'report.program_title') }}\n"
349+
" ) as program_readable_id\n"
350+
"from report"
351+
)
352+
result = parse_model_sql("my_model", sql)
353+
assert result.parse_error is None
354+
assert "program_readable_id" in result.output_columns
355+
307356
def test_var_in_where_clause_parseable(self) -> None:
308357
"""A model with '{{ var(...) }}' in WHERE must parse without error."""
309358
sql = (

0 commit comments

Comments
 (0)