refactor(tesseract): render member references as symbols - #11857
waralexrom wants to merge 4 commits into
Conversation
A member a select reads from one of its sources was resolved while rendering, by looking its name up in a per-query map. The map was built bottom-up from a select's projections and applied to everything rendered under it, so a member it failed to record silently rendered its own expression against a source that may not carry the columns for it. Introduce `MemberSymbol::ColumnRef`: a leaf that renders as the column or literal it names. It carries the member it stands for as an identity annotation rather than a dependency, so it keeps the member's name and alias and can hold any position the original held — column resolution, member-position lookup and filter matching keep working on it. A reference describes no calculation, and nothing wraps it: `RootSqlNode` dispatches it to a bare evaluate node of its own, so masking, granularity truncation and time shifts never see one. Whatever still applies to the member is expressed as a symbol standing above the reference instead. That is what kills the second map: a measure reading its row-level input from a source below is now a measure whose kind aggregates a direct-reference `SqlCall` over the reference (`measure_over_reference`), and the ordinary measure chain applies its aggregation exactly as before. Substitution is applied per select to its whole symbol environment — schema, filters, group by, order by, window partitions — as the last transform, after render modifiers are stamped. Resolution itself stays bottom-up for now; moving it into the logical plan is separate work. Two details the mechanism forced. A filter renders the dimension behind a granularity wrapper, so substituting the wrapper would compare a date range against a truncated column: substitution goes inside the wrapper. A reference is terminal — substituting one again would rebuild a wrapper around its own reference forever. `render_references` remains for one case: the SQL of a join condition is built while the FROM is still being assembled, before there is a select symbol environment to rewrite, so a subquery dimension or a pinned calc-group value named there is still resolved while rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mechanism An ORDER BY item present in the select's schema is sorted by the schema's own symbol, which the select already substituted. An item absent from it — a measure the query filters on but does not select — carries its own symbol and needs the same rewrite. The top-level query did that inline; the two multi-stage selects did not, so such an item there rendered its own expression against a source that only holds the aggregate's column. `make_order_by` now takes the select's substitutions and applies them, which puts the rewrite in one place for all three callers. Two measure lists asked `as_measure()?` of every member they held. A member expression the SQL API built sits in the same list and renders its own SQL, so asking it for a measure kind turned a renderable query into a planning error. Both loops skip what is not a measure instead. `RenderReferencesSqlNode` passes a reference straight through. A reference carries the name of the member it stands for, so looking that name up in the map would render it from the map instead of from itself. Cover the mechanisms the refactor rests on, each of which was held up until now only by the test that first found it: substitution stopping at a reference, bare and nested; the map declining to intercept one; `find_member_positions` reaching a time dimension behind a reference; and the pinned calc-group value in a multi-stage dimension join, whose context-free ON expression review found covered by neither suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…easure A multiplied measure reads its rows from the source cube joined beside the keys subquery, and a sub-query dimension that the join condition names is joined there too. That dimension's join matches on the primary key, so the key has to resolve against the cube joined for the measure; resolving it against anything out of scope at that point makes the whole select unrunnable, which no fixture would have caught. Reaching that shape needs a multiplied measure that survives as an aggregate: a count collapses into a COUNT(DISTINCT) over the join and never builds the keys subquery at all. `B.sum_foo_id` gives the fixture one, and the B to C join already names the sub_query dimension it takes to put a dimension sub-query into the same join. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 9m 16s —— View job Review: looks sound — no correctness findings; 2 low (stale rationale comment, discarded work on the pre-agg path)Full reviewTodos
What I verifiedI traced each of the six former
Nice touches: Findings
Both posted as inline comments. No prior review threads existed on this PR, so nothing to resolve or dedupe against. Nits (not posted inline)
Caveats
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11857 +/- ##
=======================================
Coverage 80.35% 80.35%
=======================================
Files 500 500
Lines 106555 106555
Branches 3881 3881
=======================================
Hits 85619 85619
Misses 20386 20386
Partials 550 550
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
… path A select over a pre-aggregation collected substitutions for every dimension, measure and `having` member and then replaced the result with the pinned calc-group values, thirty lines below the walk it invalidated. Gate the walk itself instead, so the intent stays next to the work and a collection step added above cannot survive here unnoticed. `measures_for_query` and `over_full_aggregated_source` are read further down, so they are computed before the gate rather than inside it. State what binds the primary key of a multiplied measure's source cube to the alias it is joined under: the condition renders through a context built while the FROM is still being assembled, so the cube aliases the select derives from its finished FROM never reach it. Trim `measure_over_reference` to what its body does not already say — why the `case` body and the measure filters are dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 1m 23s —— View job Re-review of
|
Summary
A member that a select reads from one of its sources was resolved while rendering, by looking its name up in a per-query map. The map was built bottom-up from the select's projections and applied to everything rendered beneath it, so a member it failed to record silently rendered its own expression against a source that may not carry the columns for it.
This replaces that map with a symbol.
MemberSymbol::ColumnRefis a leaf that renders as the column or literal it names, carrying the member it stands for as an identity annotation rather than a dependency, so it keeps the member's name and alias and can hold any position the original held — column resolution, member-position lookup and filter matching keep working on it.Changes
MemberSymbol::ColumnRef.RootSqlNodedispatches it to a bare evaluate node of its own, so masking, granularity truncation and time shifts never see one: a reference describes no calculation, and nothing may wrap it.SqlCallover the reference (measure_over_reference), which removesungrouped_measure_referencesentirely.render_referencesfor one case only: the SQL of a join condition is built while the FROM is still being assembled, before there is a select symbol environment to rewrite. Its call sites go from six to three, all of them join conditions.make_order_bytakes the select's substitutions, so an ORDER BY item absent from the schema — a measure the query filters on but does not select — is rewritten for all three callers rather than only the top-level query.Resolution itself stays bottom-up. Moving it into the logical plan is separate work.
Testing
cargo test -p cubesqlplanner --features integration-postgres— 1381 tests pass, including the integration tests that execute their generated SQL against a real Postgres and snapshot the resulting rows.New coverage for each mechanism the refactor rests on: substitution stopping at a reference (bare and nested), the render-reference map declining to intercept one,
find_member_positionsreaching a time dimension behind a reference, an ORDER BY on a filtered measure outside the selection, a pinned calc-group value in a multi-stage dimension join, and the sub-query dimension join of a multiplied measure.Not in scope: a calc group pinned to a single value renders that value into the join condition and is covered here. A calc group with several values cross-joins its values table after the join that references it, so that reference is out of scope whichever way it renders; this does not run on master either and needs the FROM ordering fixed separately.
Check List
🤖 Generated with Claude Code