DRC-3809: resolve PIVOT output columns to real source columns (CLL)#1467
Draft
danyelf wants to merge 1 commit into
Draft
DRC-3809: resolve PIVOT output columns to real source columns (CLL)#1467danyelf wants to merge 1 commit into
danyelf wants to merge 1 commit into
Conversation
sqlglot's qualify step points every projected column of a pivoted query at the pivot's table alias (`P`, or a synthesized `_0` / `_q_0` when unaliased), but sqlglot never registers that alias in `scope.sources` — the `Pivot` node hangs off the base table's `.args["pivots"]`. As a result the CLL engine fell through to its phantom-alias fallback, emitting bogus `P.JAN` dependencies, dropping all model-level lineage (`m2c` empty), and losing the real column-level parents for every pivoted column. Build a per-scope pivot map keyed by the pivot alias so those projections resolve back to their pre-pivot source columns: generated value columns become `derived` on the measure + dimension source columns, and pass-through columns resolve against the base relation. The measure/dimension source columns are also fed into `m2c` to restore model-level provenance. The resolver keys off the pivot node's structure, never the alias string (which drifts across sqlglot versions — 27.29.0 emits `_q_0`, 30.11.0 emits `_0`). Adds tests/util/test_cll.py::test_pivot covering the Snowflake explicit-alias and `select *` (synthesized-alias) cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Danyel Fisher <danyel@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: this is Claude being very excited about the PIVOT operation in particular. In general, it's important for both our system and our demos to show that CLL properly transitions in things like a parent model with a column change may become a child with a whole-model change, or vice versa.
Background (for a reader with no context)
Recce shows column-level lineage (CLL) — for each column of a dbt model, which upstream columns feed it. It computes this by parsing each model's compiled SQL with sqlglot and walking the query's scopes to connect every output column back to its real source column(s).
This PR fixes CLL for SQL
PIVOTqueries (common in Snowflake).The problem
When a query uses
PIVOT, sqlglot rewrites every projected column to reference a synthetic pivot table alias (e.g.P, or an auto-generated_q_0/_0depending on sqlglot version). But sqlglot never registers that alias as a real source in the query's scope — the pivot hangs off the base table node instead. Recce's resolver (recce/util/cll.py) had no handling for this, so it fell through to a "phantom" fallback that emitted a dependency on a table (P/_q_0) that doesn't exist in the DAG.Downstream, the dbt adapter couldn't map that phantom table to any model, so it dropped the edges entirely. The result: a pivoted model lost all of its lineage — not just the pivoted columns' column-level parents, but the model-level provenance too.
Before
For the query
select id, jan, feb from t1 pivot (sum(rev) for month in ('jan','feb')) as p(schemat1(id, month, rev)), CLL produced:In the running app the pivoted model showed no real upstream lineage — the columns pointed at a pivot alias that isn't in the graph, so the edges vanished.
After
The generated pivot value columns (
JAN,FEB) resolve to the columns the pivot actually consumes — the measure (t1.rev) and the dimension (t1.month) — and unchanged pass-through columns (id) resolve straight to their base column. No phantom node anywhere.Seen in the running app
Demonstrated on a real jaffle-shop dbt-duckdb project with a
payments_by_methodmodel =stg_paymentspivoted (sum(amount) for payment_method in (credit_card, coupon, bank_transfer, gift_card)). (There is no "before" screenshot because the pre-fix state produced no resolvable lineage at all — the phantom node dropped the edges; the "before" is the empty/broken lineage described above.)A pivot value column (
credit_card) correctly resolves as derived fromstg_payments.payment_method+stg_payments.amount:A pass-through column (
order_id) correctly resolves as passthrough fromstg_payments.order_id:And the model-level lineage graph is intact —
raw_payments → stg_payments → payments_by_method, with no phantom pivot-alias node:How
A small, surgical resolver in
_cll_select_scope/source_column_dependency(recce/util/cll.py). Before falling through to the phantom path, it builds a per-scope pivot map from the base table's pivot node and resolves:derivedon the measure + dimension source columns,passthroughon the base table,m2c) with the consumed source columns.Resolution defers to the existing base-source logic, so it works whether the pivoted relation is a raw table or a CTE/subquery. Crucially it keys off the pivot node's structure, never the alias string — so it's immune to the
_0vs_q_0synthetic-alias drift between sqlglot versions (verified: ideation ran sqlglot 27.29.0 →_q_0; implementation ran 30.11.0 →_0; both pass).Verification
tests/util/test_cll.py::ColumnLevelLineageTest::test_pivot— explicit-alias andselect *(no alias) cases, asserting the exact edges above with no phantom node. Genuinely test-first: reverting the resolver reddens it (m2c empty,JAN→Pphantom).payments_by_methodPIVOT model renders in the live Recce app with its value columns resolving tostg_payments.payment_method+stg_payments.amountandorder_idpassing through — no phantom node. (Screenshots in the workflow evidence folderdocs/spacedock/recce-lineage-bugs/_evidence/drc-3809/.)Known boundary (not a regression — possible follow-up)
The fix covers a
PIVOThanging directly off a base table. APIVOTwrapped in a subquery/CTE (... from (select ...) pivot ...) still falls to the old phantom path. Out of scope for this AC; worth a follow-up if subquery-pivots matter.Part of the DRC-2891 complex-SQL-pattern audit. Sibling UNNEST/LATERAL gap tracked separately (DRC-3807/3808). Opening as draft — validated in the workflow; pending final review.
🤖 Generated with Claude Code