Skip to content

Commit 845a674

Browse files
authored
fix(ol_dbt_cli): bound broken-column-macro collapse to prevent cross-CTE overrun (#2442)
* fix(ol_dbt_cli): bound broken-column-macro collapse to prevent cross-CTE overrun sql_parser.py's Jinja-macro-tail collapse used an unbounded lazy loop to find the next `) as alias` after a placeholder. A bare (unaliased) macro call repeated in a GROUP BY list has no real terminal, so the search ran past the GROUP BY, the CTE's closing paren, and the outer SELECT keyword before seizing on an unrelated downstream alias -- corrupting the parsed SQL and producing false yaml_sql_sync/broken_ref_columns errors on marts__combined_program_enrollment_detail (which blocked this PR's CI after rebasing onto the new dbt PR CI gate). Bound the continuation to 6 lines, which still covers the two existing orphaned-tail regression tests, and make the leading ')' before 'as' optional so a plain multi-line macro+alias (no stray tokens) resolves on the very next line instead of searching further. * fix(ol_dbt_cli): avoid invalid self-referential CTE in regression test The new test's CTE was named 'combined' and its own body did 'from combined', an invalid recursive self-reference without WITH RECURSIVE. Rename the CTE's underlying source to raw_source so the test SQL is valid while still exercising the same placeholder-collapsing behavior.
1 parent a037100 commit 845a674

2 files changed

Lines changed: 52 additions & 4 deletions

File tree

src/ol_dbt_cli/ol_dbt_cli/lib/sql_parser.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,29 @@ def _render_jinja(sql: str) -> tuple[str, list[str], list[str], dict[str, str],
197197
# , __jinja__) as array (varchar)), ', ' <- orphaned tail (array(varchar) is a cast)
198198
# ) as course_level <- real alias, must be last token on its line
199199
#
200-
# The regex uses a tempered greedy token `(?:(?!<next_jinja_col>)[^\n]*\n)*?` to
201-
# avoid consuming past the next broken-column placeholder.
200+
# The regex uses a tempered greedy token `(?:(?!<next_jinja_col>)[^\n]*\n){0,6}?` to
201+
# avoid consuming past the next broken-column placeholder. The repeat count is
202+
# capped (rather than unbounded `*?`) because a *bare* placeholder that never
203+
# gets an alias at all — e.g. the same macro call repeated, unaliased, inside a
204+
# GROUP BY list — has no real terminal to find. Left unbounded, the lazy search
205+
# happily scans past the GROUP BY, the CTE's closing paren, and even a `select`
206+
# keyword to seize on some unrelated downstream `) as alias`, corrupting the
207+
# SQL by deleting everything in between. Real broken-macro tails (see the two
208+
# test cases below) resolve within 1-4 lines, so a small bound safely covers
209+
# genuine cases while making runaway cross-CTE matches structurally impossible:
210+
# past the bound the regex simply fails to match and the bare placeholder is
211+
# left as-is (a harmless unresolved identifier) instead of eating the file.
202212
# Matches both ``__jinja__`` (regex path) and ``__macro__`` (Jinja2 path) placeholders.
203213
_BROKEN_COL_PLACEHOLDER = r"(?:__jinja__|__macro__|__undefined__)"
204214
_BROKEN_COL_RE = re.compile(
205215
rf"([ \t]*,[ \t]*){_BROKEN_COL_PLACEHOLDER}(?![ \t]+as\b)" # column-sep + placeholder, no direct alias
206216
r"[^\n]*\n" # rest of the first (broken) line
207-
rf"(?:(?![ \t]*,[ \t]*{_BROKEN_COL_PLACEHOLDER})[^\n]*\n)*?" # optional continuation lines
208-
r"[ \t]*\)[ \t]+as[ \t]+(\w+)[ \t]*(?:--[^\n]*)?(?:\n|$)", # ) as alias — must be last token on line
217+
rf"(?:(?![ \t]*,[ \t]*{_BROKEN_COL_PLACEHOLDER})[^\n]*\n){{0,6}}?" # bounded continuation lines
218+
r"[ \t]*\)?[ \t]*as[ \t]+(\w+)[ \t]*(?:--[^\n]*)?(?:\n|$)", # [)] as alias — must be last token on line.
219+
# The ')' is optional: a plain macro call (e.g. a UDF-style {{ macro(...) }})
220+
# followed by `as alias` alone on the next line has no stray tokens to
221+
# collapse, so the lazy loop must terminate there instead of overrunning
222+
# into an unrelated later `) as ...` several columns/CTEs downstream.
209223
)
210224

211225

src/ol_dbt_cli/tests/test_sql_parser.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,40 @@ def test_broken_column_macro_multiline_case(self) -> None:
270270
assert "item_id" in result.output_columns
271271
assert "item_types" in result.output_columns
272272

273+
def test_broken_column_regex_stops_at_plain_multiline_alias(self) -> None:
274+
"""A plain macro call aliased on the next line must not swallow later columns.
275+
276+
Unlike the orphaned-tail cases above, ``{{ macro(...) }}`` here has no stray
277+
tokens trailing it — the very next line is just ``as alias``. The collapse
278+
regex must terminate there instead of lazily overrunning into unrelated
279+
later columns (and even later CTEs) looking for the next ``) as ...``.
280+
"""
281+
sql = (
282+
"with combined as (\n"
283+
" select\n"
284+
" raw_source.platform_name\n"
285+
" , {{ generate_hash_id('cast(raw_source.user_id as varchar)') }}\n"
286+
" as user_hashed_id\n"
287+
" , raw_source.user_id\n"
288+
" , count(distinct raw_source.course_id) as unique_courses\n"
289+
" from raw_source\n"
290+
" group by\n"
291+
" raw_source.platform_name\n"
292+
" , {{ generate_hash_id('cast(raw_source.user_id as varchar)') }}\n"
293+
" , raw_source.user_id\n"
294+
")\n"
295+
"select\n"
296+
" combined.platform_name\n"
297+
" , combined.user_hashed_id\n"
298+
" , combined.unique_courses\n"
299+
"from combined"
300+
)
301+
result = parse_model_sql("my_model", sql)
302+
assert result.parse_error is None
303+
assert "platform_name" in result.output_columns
304+
assert "user_hashed_id" in result.output_columns
305+
assert "unique_courses" in result.output_columns
306+
273307
def test_var_in_where_clause_parseable(self) -> None:
274308
"""A model with '{{ var(...) }}' in WHERE must parse without error."""
275309
sql = (

0 commit comments

Comments
 (0)