Skip to content

fix(tesseract): Resolve join for hint-less member expressions on views - #11501

Open
MazterQyou wants to merge 1 commit into
masterfrom
tesseract/resolve-join-hint-views
Open

fix(tesseract): Resolve join for hint-less member expressions on views#11501
MazterQyou wants to merge 1 commit into
masterfrom
tesseract/resolve-join-hint-views

Conversation

@MazterQyou

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made

This PR fixes the invalid type: unit value, expected struct JoinDefinitionStatic error for queries whose member expressions on a view carry no join hints of their own, such as count(*) alongside count(distinct <view dimension>). Related tests are included.

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

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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


Review: approach is sound — 2 medium, 3 low (5 inline comments)

Full review
  • Gather context (diff, multi_fact_join_groups.rs, join_hints.rs, query_properties.rs, fixtures, seed data)
  • Understand the request (comprehensive code review)
  • Review code quality / correctness
  • Review test coverage
  • Check security & performance angles
  • Post inline comments and summary

Verdict

The diagnosis is right and the fix is in the right place. The old Ok(JoinHints::new()) branch for views produced a hint set that nothing downstream could resolve, and the bridge then failed with invalid type: unit value, expected struct JoinDefinitionStatic — an error that told the user nothing. Borrowing the query's other hints for a hint-less view member expression, and hard-erroring with a CubeError::user when there is nothing to borrow, is a clear improvement on both counts.

I also checked the pooled_hints preservation in for_measures, which reads odd at first: it is in fact load-bearing. query_properties.rs:590 calls compute_join_multi_fact_groups_with_measures(&[m]) for a single measure and then .expect()s a join group — without preserving the whole-query pooled hints, a hint-less view ME on its own would fall through to base_hints and could panic there. Worth keeping; the doc comment covers it.

Verified the new snapshot against the seed data: integration_multi_fact_tables.sql has 8 orders across 2 distinct statuses, so 2 | 8 is correct. The #[test] (non-tokio) error test is consistent with the existing sync error tests in the same file (test_subquery_join_unknown_join_type, test_subquery_join_no_cube_reference_in_on).

Findings

# Sev Where Issue
1 Medium multi_fact_join_groups.rs:126-129 pooled_hints re-appends base_hints once per measure (each mh.hints already contains it), and JoinHints::push dedups only adjacent entries → repeated items, distinct join_tree_cache key, redundant bridge resolution
2 Medium multi_fact_join_groups.rs:285 For a genuinely multi-fact query, pooled hints are the union across all facts, so the hint-less view ME forms its own join group over a fan-out tree — COUNT(*) then counts a cross product. Untested; customer_overview in the fixture is exactly this shape
3 Low multi_fact_join_groups.rs:232-240 Error text says "no other member to resolve the join from", but multi-stage measures are filtered out before pooling, so it can fire when other members do exist
4 Low multi_fact_join_groups.rs:97-101 for_measures constructs then overwrites pooled_hints; passing it into from_base_hints would make the invariant structural
5 Low member_expressions.rs:570 Stray trailing blank line at EOF

Security / performance

No security surface here — no user input reaches SQL text through this path; the change only reorders which cube hints seed join resolution. Performance impact is confined to finding 1 (avoidable join_tree_cache misses from non-canonical hint lists); the pooled_hints field itself is one extra JoinHints clone per MeasuresJoinHints, which is negligible.

Docs

Nothing user-facing changed, so no /docs-mintlify update is needed. The doc comments added to MeasuresJoinHints, for_measures and fallback_hints_for_measure are genuinely good — they explain the why (legacy-parity pooling) rather than restating the code.

What I could not verify

I did not compile or run the tests: this is a fresh checkout with no Cargo build cache for the cubesqlplanner crate, and a cold build of the Rust workspace exceeds the time available in this job. All findings above are from static reading of the code plus the fixtures; finding 2 in particular is reasoned from the join-graph shape in integration_views.yaml and should be confirmed by actually running the suggested customer_overview case. The positive-path test also only asserts against Postgres when one is available (try_execute_pg_from_options returns Option), so the snapshot may be skipped in some environments.

· branch [`tesseract/resolve-join-hint-views`](https://github.com/cube-js/cube/tree/tesseract/resolve-join-hint-views)

Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.46%. Comparing base (259188d) to head (8796a72).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11501      +/-   ##
==========================================
- Coverage   83.96%   79.46%   -4.50%     
==========================================
  Files         257      480     +223     
  Lines       80919    98861   +17942     
  Branches        0     3636    +3636     
==========================================
+ Hits        67940    78563   +10623     
- Misses      12979    19778    +6799     
- Partials        0      520     +520     
Flag Coverage Δ
cube-backend 59.23% <ø> (?)
cubesql 83.95% <ø> (-0.01%) ⬇️

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.

@MazterQyou
MazterQyou force-pushed the tesseract/resolve-join-hint-views branch from cbc785f to 55a179d Compare August 7, 2026 15:58
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MazterQyou's task in 5m 2s —— View job


Re-review: all 5 prior findings addressed — 1 medium, 3 low (4 new inline comments)

Full review
  • Gather context (new diff vs 259188d, planner, fixtures, snapshots, seed data)
  • Check which of the 5 prior findings are addressed
  • Review the new join_map fallback + mock fixture change
  • Verify snapshot numbers against seed data
  • Resolve stale threads, post new findings

Prior findings — all addressed, threads resolved

# Prior finding Resolution
1 pooled_hints re-appended base_hints per measure Now pools only the incremental own_hints, with a comment on why canonicality matters
2 Multi-fact pooled hints → own fan-out group, untested test_expr_measure_count_star_no_hints_on_multi_fact_view locks it in, and the intent is documented
3 Error text claimed "no other member" while multi-stage were filtered pre-pooling Multi-stage measures now contribute to the pool (continue after pooling), and the message is rewritten
4 for_measures constructed then overwrote pooled_hints Threaded through as inherited_pooled_hints: Option<JoinHints>
5 Trailing blank line at EOF Gone

Snapshot arithmetic — checked against integration_multi_fact_tables.sql

  • no_hints_on_view: 2 | 8 — 8 orders, 2 distinct statuses. ✅
  • no_hints_on_multi_fact_view: 8 | 5 | 13 — the fan-out over customers⋈orders⋈returns is c1 4×1=4, c2 3×2=6, c3 0→1×2=2, c4 1×0→1=1 = 13. ✅ The two real measures stay in their own narrow groups (8 and 5), so the multi-fact grouping is doing the right thing.
  • only_member_on_view_with_join_map: 4customers row count via the join-map root. ✅

New findings

# Sev Where Issue
1 Medium multi_fact_join_groups.rs:307-316 The join-map last resort only fires for multi-hop views (joinMap is filtered to len > 1), so COUNT(*) alone still hard-errors on a single-cube view like orders_view — the commonest view shape, and precisely the BI-profiling case the PR body cites
2 Low multi_fact_join_groups.rs:309-313 Root taken from the first joinMap path → depends on view cube ordering; and COUNT(*) on customer_overview means 4 alone vs 13 alongside other measures. Parity with legacy, but should be documented
3 Low mock_schema.rs:648-661 Populating join_map changes hint collection for every existing view fixture (Single(x)Vector([x]), a distinct join_tree_cache key). Only 3 assertions needed updating, which is plausible — please confirm the full suite is green
4 Low multi_fact_join_groups.rs:112-120 JoinHints::push dedups only adjacent Singles, so identical Vectors still repeat in pooled_hints — pre-existing, but pooled_hints is now a cache key of its own

Finding 1 is the only one I'd want a decision on before merge, and "out of scope, follow-up filed" is a perfectly good answer — the current state is strictly better than the invalid type: unit value, expected struct JoinDefinitionStatic failure either way. The rest are comment/robustness polish.

Code quality

The from_base_hints rewrite is a genuine improvement over the previous filter-then-map: one pass, and the "multi-stage measures get no entry but do count towards the pool" rule is now stated where it happens rather than implied by a filtered_measures vector two blocks up. The inherited flag reads slightly better than a is_some() re-check would, though match inherited_pooled_hints { Some(p) => …, None => … } would make the two modes structurally exclusive rather than coupled through a bool.

The error message is specific and actionable. It interpolates cube_name twice (as the referenced cube and as the join-map owner) which reads a little repetitively for the common case where they're the same string, but it is correct.

Security / performance

No security surface: nothing user-controlled reaches SQL text through this path, and the change only reorders which cube hints seed join resolution. Performance is a wash — collect_join_hints is now also called for multi-stage measures (one extra traversal each, previously skipped), offset by no longer cloning base_hints into the pool per measure. pooled_hints adds one JoinHints clone per MeasuresJoinHints.

Testing

Four new tests covering the three fallback branches plus the terminal error, which is the right decomposition. Two gaps: the positive assertions only fire when a Postgres is reachable (try_execute_pg_from_options returns Option), so in environments without one those three tests degrade to "the planner didn't panic"; and there's no case for a hint-less view ME alongside only multi-stage measures, which is the path the reworked pooling in from_base_hints specifically enables.

Docs

Nothing user-facing changed, so no /docs-mintlify update is needed. The doc comments carry real weight here — they explain the legacy-parity reasoning rather than restating the code, and the per-test comments state the expected numbers and why, which is what makes the 13 in the multi-fact snapshot reviewable at all.

What I could not verify

I could not build or run the tests — cargo check on the cubesqlplanner crate isn't permitted in this job's sandbox, and this is a cold checkout with no build cache. Every finding above is from static reading of the diff, the fixtures, the seed SQL and the JS schema compiler; the snapshot arithmetic is hand-computed. Finding 3 in particular is a request for confirmation, not a claimed regression.

· branch [`tesseract/resolve-join-hint-views`](https://github.com/cube-js/cube/tree/tesseract/resolve-join-hint-views)

Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
Comment on lines +648 to +661
// Like the schema compiler, only multi-hop join paths land in the join
// map: a direct cube needs no path to be reached.
let join_map = self
.view_cubes
.iter()
.map(|view_cube| {
view_cube
.join_path
.split('.')
.map(|part| part.to_string())
.collect::<Vec<_>>()
})
.filter(|path| path.len() > 1)
.collect::<Vec<_>>();

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.

Good fidelity fix — the mock previously left join_map as None, so no view test exercised the enrichment in collect_join_hints (join_hints_collector.rs:106-123) at all.

Worth calling out that this is a behaviour change for every existing view fixture, not just the new tests: with a join_map present, a root-cube member's hint is now rewritten from Single("cube_a") to Vector(["cube_a"]) (path[0..=0]). The three assertion updates in tests/join_hints_collector.rs are the visible part, but JoinHints is the join_tree_cache key (Hash/Eq) and the value handed to join_for_hints, so Single(x) and Vector([x]) are distinct keys everywhere. That's a wide blast radius for a fixture tweak — could you confirm the full cubesqlplanner suite (including the SQL snapshot tests over integration_views.yaml / orders_with_customers) is green with no regenerated snapshots? I wasn't able to build the crate in this job to check myself.

Minor fidelity nit: production derives the path via evaluateReferences(null, it.joinPath, { collectJoinHints: true }) and pushes one entry per cube in includedCubes while processing dimensions only (CubeSymbols.ts:817-825), whereas this splits the raw join_path over every view_cube. Equivalent for these fixtures; a divergence to keep in mind if a fixture ever gets a cube with no included dimensions.

Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
@MazterQyou
MazterQyou force-pushed the tesseract/resolve-join-hint-views branch from 55a179d to 8796a72 Compare August 7, 2026 16:34
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MazterQyou's task in 4m 36s —— View job


Re-review: 3 of 4 prior findings addressed — 0 medium, 2 low (2 new inline comments)

Full review
  • Gather context (current diff vs commit parent, planner, fixtures, snapshots, seed data)
  • Check which of the 4 prior unresolved findings are addressed
  • Review the new ambiguous-root error + two_roots_view fixture + push dedup change
  • Check blast radius of the shared fixture addition
  • Resolve stale threads, post new findings

Prior findings

# Prior finding Resolution
1 Join-map last resort only rescues multi-hop views; single-cube views still error Documented as an explicit known limitation on test_expr_measure_count_star_only_member_on_view, with the two lift options and why they're out of scope. Thread resolved — the ask was "say so if intentional", and it now does.
2 Root taken from the first join-map path (order-dependent); query-dependent meaning undocumented Multiple distinct roots now raise a CubeError::user instead of picking arbitrarily, covered by two_roots_view + ..._with_ambiguous_join_map; the query-dependent meaning is spelled out on fallback_hints_for_measure. Thread resolved.
3 join_map in the mock changes hint collection for every view fixture — asked for confirmation the suite is green Left open. The fidelity divergence is now documented in the mock, but I still can't run the crate (see below), so the confirmation ask stands.
4 Vector repeats not deduped in push push now drops any verbatim repeat of the tail, with test_push_skips_repeated_vector covering both the drop and the "adjacent only" boundary. Thread resolved.

What I re-verified

  • The new ambiguous-root error is narrow: fallback_hints_for_measure only reaches it when pooled_hints is empty, i.e. the hint-less view ME is the sole contributor of hints in the whole query. A two-root view queried alongside any real member still resolves through pooling, so this can't reject anything that used to work — the prior behaviour for that shape was the invalid type: unit value bridge failure anyway.
  • customer_overview's mock join map is [[customers, orders], [customers, returns]] → one unique root customers → the 4 snapshot. two_roots_view's is [[orders, customers], [returns, customers]] → two roots → error. Both consistent with the > 1 path filter.
  • Adding two_roots_view to the shared integration_views.yaml is safe: no test in tests/integration/views.rs enumerates views or asserts a count, and its two includes (customers.city, customers.name) exist and don't collide.
  • for_measures is only ever called with subsets of the query's own measures (query_properties.rs:590, multiplied_measures_query_planner.rs:71,117), so inheriting pooled hints rather than recomputing them is sound — the subset's hints are already in the parent pool.

New findings

# Sev Where Issue
1 Low join_hints.rs:9-10 The new "canonical, because it's a cache key" claim isn't enforced: from_items bypasses push, and it's the exit path of collect_join_hints; prefix subsumption ([V[a], V[a,b]] vs [V[a,b]]) still yields distinct keys for equivalent hints
2 Low multi_fact_join_groups.rs:117-129 The "multi-stage measures pool but get no entry" branch — the one that makes the reworked error message honest — has no test; plus collect_join_hints runs a wasted full traversal per multi-stage measure on the inherited path

Neither blocks. Finding 1 is about a doc claim over-promising an invariant one constructor can break; finding 2 is a coverage gap plus a mechanical reorder.

Code quality

The Option<JoinHints> threading reads well, and moving the multi-stage check into the loop after pooling puts the rule where it happens. The inherited bool is the one spot I'd still nudge — the two modes are mutually exclusive but coupled through a flag rather than structurally; the suggestion in finding 2 makes it slightly worse in that respect, so a match over the Option up front would be the cleaner version of both.

The ambiguous-root rejection is the right call over picking a root: the alternative is a silent result change on a YAML reordering with no diagnostic. The error text names the actual cause. The doc block on fallback_hints_for_measure has grown long but every paragraph earns its place — the fallback chain, the join-map last resort with its limitation, and the query-dependent-meaning warning are all things a reader would otherwise have to reconstruct.

Testing

Five tests now, covering all three fallback branches plus both terminal errors, which is the right decomposition. Remaining gaps: the multi-stage-only pooling path (finding 2), and the three positive assertions only fire when a Postgres is reachable (try_execute_pg_from_options returns Option) — without one they degrade to "the planner didn't panic". The two error tests are sync #[test] and always assert, consistent with the existing error tests in the file.

Security / performance

No security surface — nothing user-controlled reaches SQL text through this path; the change only decides which cube hints seed join resolution. Performance is roughly neutral: collect_join_hints now also runs for multi-stage measures (finding 2 trims the avoidable half), offset by no longer cloning base_hints into the pool per measure, and the push change removes some cache-key non-canonicality rather than adding any.

Docs

Nothing user-facing changed, so no /docs-mintlify update is needed. The comments carry real weight in this round — the mock's note on why join_map filters single-element paths (and what that does to Single vs Vector hints) is the kind of thing that would otherwise be rediscovered by whoever next debugs a cache-key mismatch.

What I could not verify

I could not build or run anything: there is no Cargo target dir in this checkout and a cold build of the Rust workspace exceeds this job's budget; git fetch origin master is also not permitted here, so the diff was taken against the commit's parent (259188d, which is the current master tip in this checkout) rather than a fetched origin/master. The previously-reviewed head (55a179d) isn't present locally either, so the "prior findings" table is judged from the current file contents, not a commit-to-commit diff. Snapshot arithmetic is hand-computed against integration_multi_fact_tables.sql. Prior finding 3 remains a request for confirmation, not a claimed regression.

· branch tesseract/resolve-join-hint-views ·

Comment on lines +9 to +10
/// Keeping the list free of adjacent repeats keeps it canonical, which
/// matters because `JoinHints` is used as a join tree cache key.

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.

The verbatim-repeat rule closes the Vector gap from the last round, and the test pins down that only adjacent repeats go. Two things keep the "canonical, because it's a cache key" claim weaker than it reads, both worth a word in this comment (or a follow-up):

  1. from_items bypasses push entirely (line 21-23), and it is the exit path of the main producer — collect_join_hints rebuilds the list with JoinHints::from_items(collected_hints) after the join-map enrichment (join_hints_collector.rs:125). So a hint list can leave the collector in a form push would never have produced.

  2. Prefix subsumption isn't handled. For a view with join map [customers, orders], a measure touching both cubes collects [Single(customers), Single(orders)], which enrichment rewrites to [Vector([customers]), Vector([customers, orders])]. The first item is a strict prefix of the second — semantically nothing, but a distinct Hash/Eq from the minimal [Vector([customers, orders])], so it's a separate join_tree_cache entry and a separate JoinKey (hence potentially a separate join group).

Neither is introduced here, and (2) is now more reachable than before this PR because the mock — and any real view — gives root-cube members a Vector form. Not a blocker; the doc comment just shouldn't promise an invariant from_items can break.

Comment on lines 117 to 129
for m in measures {
if !has_multi_stage_members(m, true)? {
filtered_measures.push(m.clone());
let own_hints = collect_join_hints(m)?;
// Pool the incremental hints only: `base_hints` is already in there,
// and re-appending it per measure would make the hint list - and with
// it the join tree cache key - non-canonical.
if !inherited {
pooled_hints.extend(&own_hints);
}
// Multi-stage measures plan their joins separately, so they get no
// entry of their own - but their hints still count towards the pool.
if has_multi_stage_members(m, true)? {
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.

Pooling multi-stage measures while still giving them no measure_hints entry is exactly the right resolution of the previous wording problem — the message can now honestly claim nothing in the query offers a cube.

Two follow-ups on this loop:

Still untested. This is the branch that makes COUNT(*) on a view resolvable when the only other members are multi-stage, and nothing in the suite exercises it. All four new tests use plain measures or nothing at all, so the continue-after-pooling behaviour is load-bearing but unverified. A case with a hint-less view ME plus a single rolling-window / multi-stage measure on the same view would cover it, and would also pin down which tree the ME lands in — note it's a tree assembled from a measure that is planned in its own subquery, which is a semantics worth locking into a snapshot rather than leaving implicit.

Wasted traversal in the inherited path. When inherited is true, own_hints is unused for multi-stage measures, yet collect_join_hints(m) still runs a full symbol traversal before the continue. for_measures is called per multiplied-measure bucket and per measure in query_properties.rs:590, so this repeats. Cheap reorder:

Suggested change
for m in measures {
if !has_multi_stage_members(m, true)? {
filtered_measures.push(m.clone());
let own_hints = collect_join_hints(m)?;
// Pool the incremental hints only: `base_hints` is already in there,
// and re-appending it per measure would make the hint list - and with
// it the join tree cache key - non-canonical.
if !inherited {
pooled_hints.extend(&own_hints);
}
// Multi-stage measures plan their joins separately, so they get no
// entry of their own - but their hints still count towards the pool.
if has_multi_stage_members(m, true)? {
continue;
}
for m in measures {
let is_multi_stage = has_multi_stage_members(m, true)?;
// Multi-stage measures plan their joins separately, so they get no
// entry of their own - but their hints still count towards the pool,
// which is already filled in when it is inherited.
if inherited && is_multi_stage {
continue;
}
let own_hints = collect_join_hints(m)?;
// Pool the incremental hints only: `base_hints` is already in there,
// and re-appending it per measure would make the hint list - and with
// it the join tree cache key - non-canonical.
if !inherited {
pooled_hints.extend(&own_hints);
}
if is_multi_stage {
continue;
}

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