Skip to content

refactor(tesseract): unify MemberSymbol dependency traversal behind a single declaration - #11409

Merged
waralexrom merged 3 commits into
masterfrom
tesseract-symbol-dependencies-refactor
Jul 30, 2026
Merged

refactor(tesseract): unify MemberSymbol dependency traversal behind a single declaration#11409
waralexrom merged 3 commits into
masterfrom
tesseract-symbol-dependencies-refactor

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

Unifies how MemberSymbol dependencies are enumerated and transformed in Tesseract. Previously every symbol node hand-implemented up to six parallel traversals (get_dependencies, extract_symbol_deps, get_cube_refs, extract_cube_refs, apply_to_deps, SqlCall::apply_recursive) that each enumerated the same child slots independently — and had already drifted apart. Now each node declares its dependency slots exactly once, and both the read side and the rebuild side are derived from that single declaration, so they can never disagree.

Changes

  • New planner/symbols/deps.rs: SymbolDeps trait (visit_deps / visit_deps_mut), DepVisitor / DepVisitorMut sinks, and generic algorithms (collect_deps, collect_cube_refs, apply_recursive, apply_to_deps) written once.
  • symbol_deps! macro for struct nodes: one field list with modes dep / dep_symbol / dep_transparent / skip, generating both visit methods. Full destructuring without .. makes adding a struct field a compile error until the field is classified. Enum nodes implement the trait with exhaustive matches.
  • All 18 nodes converted; ~90 hand-written traversal bodies deleted. Old public signatures remain as thin wrappers, so call sites are untouched.
  • A dependency is defined as a symbol / cube ref whose rendered SQL enters the node's SQL. Multi-stage grain / filter member lists are annotations matched by full_name, not dependencies — apply_to_deps no longer rewrites them (MultiStageProperties::apply_to_deps deleted).
  • TimeDimensionSymbol::base_symbol is a dep_transparent slot: reads emit the base's dependencies (never the base itself), transforms receive the slot — codifying the semantics every collector already assumed.
  • Transform semantics unified: f is applied to every node exactly once, recursion is handled by the algorithm.
  • Slot order is a contract (declaration order = emission order); reference symbols resolve to their first dependency.
  • iter_sql_calls/validate, owned_by_cube and DebugSql intentionally stay bespoke.
  • New tests pin the TimeDimension dependency contract (look-through reads, base as a transform slot, wrapper identity preservation, read/transform slot agreement).

Testing

  • cargo test -p cubesqlplanner: 1083 passed.
  • Full schema-compiler postgres integration suite under CUBEJS_TESSERACT_SQL_PLANNER=true: 43/43 suites, 496 tests passed.
  • cargo check --workspace clean; slot order and composition verified against the removed code node-by-node.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the rust Pull requests that update Rust code label Jul 29, 2026
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 5m 0s —— View job


Reviewed — solid refactor, but one behavioral regression to resolve before merge: 1 high, 1 medium, 4 low

Full review
  • Gather context (diff vs b76fc13~1, 22 files)
  • Review deps.rs core trait + generic algorithms
  • Review symbol_deps! macro & all per-node declarations
  • Verify slot order / composition parity vs the removed code, node by node
  • Review the apply_to_deps semantics change (multi-stage grain/filter)
  • Review tests
  • Post review

General

This is a genuinely good consolidation. Six parallel traversals collapsing into one declaration per node is the right shape, the full-destructuring-without-.. trick is a real compile-time guard, and the dep_transparent mode finally names a semantic that every collector was silently assuming. I checked slot order against the deleted code for all 18 nodes — read-side emission order is preserved everywhere (DimensionSymbol: kind → mask; MeasureSymbol: kind → filters → drill_filters → order_by → case → mask; CaseDimension: member_sql → case; TimeDimensionSymbol: granularity calendar_sql → base's deps). reference_member's "first dependency" contract therefore still resolves to the same symbol. Cube-ref collection matches too, including the look-through on TimeDimensionSymbol.

The new test file is well aimed — transform_visits_base_node_once and read_and_transform_agree_on_slots pin exactly the two invariants that were previously only implicit.

I was not able to run cargo check / cargo test in this environment (the sandbox declined the build command), so the findings below come from reading the code and tracing call sites, not from a reproduction. The PR reports 1083 unit tests and the postgres integration suite green.

Findings

# Severity Where Issue
1 High measure_symbol.rs:113 multi_stage: skip drops transform-side rewriting of grain.include, which is rendered (not name-matched)
2 Medium dimension_symbol.rs:56 Same, via LogicalMultiStageDimension::join_dimensions
3 Low deps.rs:3-8 Module doc overstates "grain / filter member lists are annotations"
4 Low member_expression_symbol.rs:35 PatchedSymbol / base_symbol now recursed into on transform — behavior fix, undocumented, untested for PatchedSymbol
5 Low sql_call.rs:546 visit_deps_mut deep-copies every SqlCall unconditionally, destroying Rc sharing
6 Low deps.rs:29-35 ControlFlow return is dead generality — no visitor ever breaks

1 — the one that matters

The PR justifies multi_stage: skip with "grain / filter member lists are annotations matched by full_name". I traced all four lists:

list consumer annotation?
grain.exclude / grain.keep_only partition_filtermatches_grain_reference ✅ name-matched
filter.exclude / filter.keep_only filter_directive_match_names ✅ name-matched
filter.include_* already .clone()d verbatim by the old code ✅ unchanged
grain.include new_state.add_dimensions(...), join_dimensions() rendered

grain.include symbols become selected dimensions of the multi-stage CTE and their SQL is emitted. apply_static_filter_to_symbol used to rewrite them (via the now-deleted MultiStageProperties::apply_to_deps); it no longer does, and the CTE state is a QueryProperties::clone() + add_dimensions, so apply_static_filters() — which only runs inside build() — never re-applies. A type: switch dimension in grain.include under a matching query filter would render its unpruned CASE inside the CTE while the outer query expects the pruned one, same alias. Details and a suggested fix are in the inline comment.

The rest of the multi-stage reasoning is correct; only include needs to stay a transform slot.

On the macro's safety guarantee

Worth being precise about what the full destructuring buys: it forces a new field to be classified, but nothing stops a real dependency from being classified skip — which is exactly how finding #1 arises. If you want a stronger net, the cheapest thing is a test per node asserting the emitted slot count for a fixture symbol; a mis-classification then shows up as a failing count rather than as silently-missing SQL.

Security / performance

No security surface here — no input parsing, no auth, no SQL string construction changes. Performance is neutral-to-positive on the read side (no more intermediate Vec per level in the nested collectors) and neutral on the transform side, with the optional Rc-sharing improvement noted in #5.

· branch [`tesseract-symbol-dependencies-refactor`](https://github.com/cube-js/cube/tree/tesseract-symbol-dependencies-refactor)

Comment on lines +3 to +8
//! A *dependency* of a symbol is another member symbol (or a cube
//! reference) whose rendered SQL becomes part of that symbol's SQL.
//! Symbol references that only name members without rendering them —
//! multi-stage grain / filter member lists, time-shift targets — are
//! annotations, not dependencies: they are matched by `full_name` and
//! are invisible to the traversals defined here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module doc is the load-bearing justification for multi_stage: skip, and it overstates the case: grain.include entries are rendered (pushed into CTE state dimensions / join dimensions), so they don't fit the "matched by full_name" definition. grain.exclude, grain.keep_only, filter.exclude, filter.keep_only and time-shift targets do. Worth narrowing the sentence to name those specifically rather than "grain / filter member lists" — otherwise the next person reading this doc will classify a new list as an annotation on the same reasoning.

fn visit_deps_mut(&mut self, visitor: &mut dyn DepVisitorMut) -> Result<(), CubeError> {
match self {
Self::SqlCall(sql_call) => sql_call.visit_deps_mut(visitor),
Self::PatchedSymbol(symbol) => visitor.symbol(symbol),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change worth calling out explicitly in the PR body (and covering with a test): the old MemberExpressionSymbol::apply_to_deps did *member_symbol = f(member_symbol)?f only, no recursion into the patched symbol's own dependencies. Now visitor.symbol(symbol) routes through ApplyVisitorapply_recursive, so f also descends into the patched symbol's subtree.

Same delta for TimeDimensionSymbol::base_symbol (old: result.base_symbol = f(&self.base_symbol)?). The new semantics look like the correct ones — a static filter or multiplied-count rewrite previously stopped one level short for PatchedSymbol / time-dimension bases — but that makes this PR a behavior fix, not a pure refactor, and the SQL of affected models can change. transform_visits_base_node_once pins the TimeDimension side; there's no equivalent test for PatchedSymbol, which would be cheap to add alongside it.

Comment on lines +546 to +553
fn visit_deps_mut(&mut self, visitor: &mut dyn DepVisitorMut) -> Result<(), CubeError> {
let mut call = (**self).clone();
for dep in call.deps.iter_mut() {
if let SqlDependency::Symbol(s) = dep {
visitor.symbol(s)?;
}
}
Ok(Rc::new(result))
*self = Rc::new(call);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional (not a regression — the old apply_recursive did the same): this deep-copies the SqlCall and reallocates the Rc on every traversal, including for calls that have no Symbol deps at all. Since apply_to_deps now runs over the whole tree for every node, an identity transform rebuilds every SqlCall in the graph and destroys all Rc sharing. A cheap early-out keeps the common case allocation-free:

fn visit_deps_mut(&mut self, visitor: &mut dyn DepVisitorMut) -> Result<(), CubeError> {
    if !self.deps.iter().any(|d| matches!(d, SqlDependency::Symbol(_))) {
        return Ok(());
    }
    let mut call = (**self).clone();
    ...
}

Same idea would apply to MemberSymbol::visit_deps_mut, though there the clone is harder to avoid.

Comment on lines +29 to +35
pub trait DepVisitor {
fn symbol(&mut self, symbol: &Rc<MemberSymbol>) -> ControlFlow<()>;

fn cube_ref(&mut self, _cube_ref: &CubeRef) -> ControlFlow<()> {
ControlFlow::Continue(())
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the ControlFlow<()> return is currently dead generality — neither collect_deps nor collect_cube_refs ever returns Break, and both discard the outer result (let _ = node.visit_deps(...)). It costs a ? on every generated arm and an ops::ControlFlow import in every node file. If short-circuiting isn't on the roadmap, fn symbol(&mut self, symbol: &Rc<MemberSymbol>) returning () would make the macro arms and the enum impls noticeably plainer; if it is (e.g. a future find_first_dep), a one-line comment saying so would keep the next reader from "simplifying" it away.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.43%. Comparing base (6ca51d8) to head (6369eeb).

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11409      +/-   ##
==========================================
- Coverage   83.94%   79.43%   -4.52%     
==========================================
  Files         257      480     +223     
  Lines       80887    98778   +17891     
  Branches        0     3636    +3636     
==========================================
+ Hits        67904    78460   +10556     
- Misses      12983    19800    +6817     
- Partials        0      518     +518     
Flag Coverage Δ
cube-backend 59.00% <ø> (?)
cubesql 83.94% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 4m 5s —— View job


Re-reviewed after 8a1b7c7 — my high-severity grain.include finding is withdrawn; multi_stage: skip is correct. Remaining: 3 low, 1 coverage nit

Full review
  • Gather context (new commit 8a1b7c7: tests + yaml fixture only, no source changes)
  • Review the new include_switch integration tests + snapshots
  • Re-verify the grain.include claim against the actual leaf-CTE construction
  • Check the residual join_dimensions (multi-stage dimension) path
  • Re-check prior findings ResultSet pivot methods documentation  #3ResultSet.normalizePivotConfig() fix #6
  • Resolve my two now-stale threads

What changed

8a1b7c7 adds only tests + fixtures — src/tests/integration/multi_stage/include_switch.rs (4 tests), 72 lines of integration_multi_stage.yaml, 4 snapshots. No planner source was touched.

My finding #1 was wrong — retracted

@waralexrom is right that include belongs to the substitution point, and the mechanism that makes it work is one I missed: the leaf CTE does not inherit the cloned state's symbols verbatim. MemberQueryPlanner::plan_for_leaf_cte_query builds a fresh QueryProperties::builder()…build()? (member_query_planner.rs:448-464) from description.state(), and build() routes through impl From<QueryProperties> for Result<Rc<…>>apply_static_filters() (query_properties.rs:216). So every grain.include dimension that landed in new_state via add_dimensions is re-pruned inside the leaf, against that leaf's own dimensions_filters.

That is exactly the right filter set, and it's the one the metadata copy could never have. My step 4 ("apply_static_filters() only runs in build(), so it is never re-applied") was the error — it is re-applied, because the leaf calls build().

The new tests pin both directions and would have caught the old behavior:

  • test_include_case_switch_dim — outer orders.status = 'completed' ⇒ no CASE anywhere, include copy pruned inside the CTE.
  • test_include_case_switch_dim_with_filter_excluded_from_leaffilter: { exclude: [orders.status] }CASE survives in the leaf. Under the old MultiStageProperties::apply_to_deps the metadata copy was already pruned with the parent's filters and apply_static_filters can't un-prune, so this test would have failed on master. It's a regression test for a pre-existing bug, not just for the refactor.
  • Plus calc-group coverage (include_calc_group_dim, include_case_over_calc_group).

Nice touch that all four assert on build_sql first and only snapshot when try_execute_pg is available, so they still assert something without a live postgres.

I've resolved my two threads on measure_symbol.rs:113 and dimension_symbol.rs:56.

Residual (low) — the multi-stage dimension include path is still untested

MultiStageDimensionCalculation::join_dimensions (logical_plan/multistage/dimension.rs:49-56) reads dimension.multi_stage().grain.include straight off the metadata, and those symbols are rendered on the outer side of the join at physical_plan_builder/builder.rs:190-193 (Expr::new_member_with_context). Unlike the measure path there's no QueryProperties::build() in between, so this side keeps the unpruned symbol while the CTE column was produced pruned.

I traced whether that can actually produce wrong SQL and I don't think it can: apply_static_filter_to_symbol only rewrites case bodies (static_filter.rs:43-62), pruning is value-preserving on exactly the rows the authorizing filter keeps, and both sides of the join are evaluated under that filter — calc groups take the separate is_calc_group branch (builder.rs:174-178) and are unaffected either way. So: not a bug, but it's the one grain.include consumer that doesn't re-resolve, and none of the four new tests exercise it (all four use multi-stage measures). A fifth test — a multi_stage: true dimension with add_group_by: [orders.status_case_label] under a matching filter — would be cheap given the fixture is already there, and would lock down the reasoning above rather than leaving it to a future reader to re-derive.

Prior findings still open

# Severity Where Status
3 Low deps.rs:8 Still applies, with a shifted rationale: the definition ("SQL becomes part of that symbol's SQL") is right, but "matched by full_name" isn't true of grain.include — it's re-resolved at the leaf's QueryProperties::build(). One sentence pointing there would save the next reader the trace I just did.
4 Low member_expression_symbol.rs:35 Still applies — PatchedSymbol transform now recurses where it previously applied f only; untested.
5 Low sql_call.rs:553 Still applies — optional Rc-sharing early-out.
6 Low deps.rs:35 Still applies — dead ControlFlow generality.

Build/test

I could not run cargo test here — the sandbox declined the build command, same as on the previous pass. Everything above is from reading the code and tracing call sites. Please confirm cargo test -p cubesqlplanner locally/CI, in particular that the four new snapshots were generated against a live postgres rather than skipped.

Verdict

With the mechanism confirmed, multi_stage: skip in the symbol_deps! declarations is not just safe but more correct than the code it replaces, and the module doc's core definition holds. The remaining items are documentation, one untested behavior delta, and two nits — nothing blocking.

· branch [`tesseract-symbol-dependencies-refactor`](https://github.com/cube-js/cube/tree/tesseract-symbol-dependencies-refactor)

… single declaration

Each symbol node now declares its dependency slots exactly once — via the
symbol_deps! macro for structs (full destructuring, so a new field fails to
compile until classified) or a hand-written SymbolDeps impl for enums — and
both the read side (collect_deps, collect_cube_refs) and the rebuild side
(apply_recursive, apply_to_deps) are derived from that declaration by generic
algorithms in planner/symbols/deps.rs. This replaces the per-node hand-written
get_dependencies / extract_symbol_deps / get_cube_refs / extract_cube_refs /
apply_to_deps bodies, which enumerated the same slots independently.

Semantic points:
- A dependency is a symbol or cube ref whose rendered SQL enters the node's
  SQL. Multi-stage grain/filter member lists are annotations matched by
  full_name, not dependencies: apply_to_deps no longer rewrites them.
- TimeDimensionSymbol::base_symbol is a transparent slot: reads emit the
  base's dependencies, transforms receive the slot itself.
- Transform semantics are uniform: f is applied to every symbol node exactly
  once, recursion is handled by the algorithm.
- Slot order is a contract (declaration order); reference symbols resolve to
  their first dependency.
- iter_sql_calls/validate, owned_by_cube and DebugSql stay bespoke.
The base symbol is a granularity view, not a dependency: reads look
through it (emitting the base's own dependencies), while transforms
receive it as a replaceable slot and the wrapper keeps its identity.
…ge include grain

Include grain refs are selected inside the multi-stage CTEs; case
pruning and calc-group value resolution follow each leaf's own filters
(the leaf rebuilds its QueryProperties, re-applying static filters), so
a leaf whose filter directive excludes the switch filter keeps the full
CASE while a filtered leaf renders the pruned branch.
@waralexrom
waralexrom force-pushed the tesseract-symbol-dependencies-refactor branch from 8a1b7c7 to 6369eeb Compare July 30, 2026 15:56
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 49s —— View job


Re-checked after the latest push — rebase only, no source or test changes. Review state unchanged: 4 low, 1 coverage nit, nothing blocking.

Full review

What this push was

The synchronize event is a rebase onto current master, not new work:

  • git merge-base HEAD origin/master == 6ca51d8 (master tip) — the branch now contains master rather than trailing it by the 27 commits Codecov flagged earlier.
  • The three commits are byte-identical in effect to what I reviewed at 8a1b7c7: same subjects (6ab0573 refactor, 75ec1cc TimeDimension tests, 6369eeb include-switch tests), same 29 files, same +984 / −732.
  • No planner source was touched by the rebase. git log origin/master -- rust/cube/cubesqlplanner shows no master commits landing in this tree in the rebase window, so there was nothing for the rebase to interact with — no new symbol node or field that the symbol_deps! full destructuring would need to classify.

I re-grepped for orphaned traversals: the only remaining get_dependencies / get_cube_refs definitions are the thin wrappers in sql_call.rs:511, member_symbol.rs:163/167, dimension_symbol.rs:243, measure_symbol.rs:380, time_dimension_symbol.rs:180, member_expression_symbol.rs:159 — all routed through deps.rs. No hand-written traversal crept back in.

Standing verdict (unchanged from the 8a1b7c7 pass)

My original high-severity grain.include finding stays withdrawnmulti_stage: skip is correct, because MemberQueryPlanner::plan_for_leaf_cte_query builds a fresh QueryProperties…build() which re-runs apply_static_filters() against the leaf's own filter set. That's the right filter set and the one a metadata-side rewrite could never have. Both threads on measure_symbol.rs:113 and dimension_symbol.rs:56 remain resolved.

Open findings — all four re-verified against the current head, all still apply

I re-read each site rather than assuming; no new inline comments posted since the existing unresolved threads cover the same lines and the same root causes.

# Severity Where Status
3 Low deps.rs:8 Module doc still says grain/filter member lists are "matched by full_name". True of grain.exclude / keep_only and filter.exclude / keep_only; grain.include is instead re-resolved at the leaf's QueryProperties::build(). One sentence pointing there saves the next reader the trace.
4 Low member_expression_symbol.rs:35 Still applies. Self::PatchedSymbol(symbol) => visitor.symbol(symbol) in visit_deps_mut routes through ApplyVisitorapply_recursive, where the old code applied f only. Correct semantics, but a behavior delta with no PatchedSymbol test (transform_visits_base_node_once pins only the TimeDimension side).
5 Low sql_call.rs:546 Still applies. visit_deps_mut does (**self).clone() unconditionally, including for calls with zero SqlDependency::Symbol deps — an identity transform rebuilds every SqlCall in the graph and drops Rc sharing. Early-out suggested in the thread.
6 Low deps.rs:35 Still applies. Both collect_deps and collect_cube_refs do let _ = node.visit_deps(...) and neither ever returns Break, so ControlFlow<()> costs a ? per generated arm for no current benefit. Either drop it or leave a one-line note that short-circuiting is planned.

Coverage nit (also unchanged)

MultiStageDimensionCalculation::join_dimensions is still the one grain.include consumer that reads the metadata symbol directly with no intervening QueryProperties::build(). I traced it again and still don't think it can produce wrong SQL — apply_static_filter_to_symbol only prunes case bodies, pruning is value-preserving on the rows the authorizing filter keeps, and calc groups take the separate is_calc_group branch. But all four tests in include_switch.rs use multi-stage measures (confirmed: every test block is measures: + optional dimensions:; none declares a multi_stage: true dimension). A fifth test — a multi-stage dimension with add_group_by: [orders.status_case_label] under a matching filter — would lock that reasoning down cheaply, since the fixture already exists.

Build

Same limitation as the two prior passes: the sandbox declined cargo check / cargo test, so everything above is from reading code and tracing call sites. Please confirm CI is green post-rebase — in particular that the four include_switch snapshots were generated against a live postgres rather than skipped, since try_execute_pg availability is what gates them.

Verdict

Nothing changed that would change the assessment. The refactor is sound, multi_stage: skip is more correct than the code it replaces, and the four open items are documentation, one untested behavior delta, and two nits. Approved by @ovr already; fine to merge with or without the low-severity cleanups.

· branch [`tesseract-symbol-dependencies-refactor`](https://github.com/cube-js/cube/tree/tesseract-symbol-dependencies-refactor)

@waralexrom
waralexrom merged commit d8d009b into master Jul 30, 2026
262 of 269 checks passed
@waralexrom
waralexrom deleted the tesseract-symbol-dependencies-refactor branch July 30, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants