Skip to content

fix(schema-compiler): keep a view's independent join_path root off its own longer paths - #11817

Open
waralexrom wants to merge 3 commits into
masterfrom
cube-view-independent-join-path-root
Open

waralexrom wants to merge 3 commits into
masterfrom
cube-view-independent-join-path-root

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Fixes #11680.

Problem

A view can reach the same cube two ways: at the root of its join tree
(join_path: boards) and as the tail of a longer path declared for another
member (join_path: locations.boards). A query that touches only the members
of the independent root was planned as if it needed the fan-out join through
locations:

  • its leaf measures resolved to the dotted form (locations.boards.good_count
    instead of boards.good_count);
  • it counted as having multiplied measures, which puts pre-aggregation matching
    on the strict path that requires the dimensions to be equal;
  • a rollup declared on boards alone therefore stopped matching, and the query
    fell back to scanning SELECT DISTINCT ... FROM locations LEFT JOIN boards -
    a cube it never references.

The same query straight against the cube, or through a view with independent
roots only, matched the rollup fine.

Cause

A view records the join path of every cube it includes through more than one
segment, and a bare join hint into any cube on such a path is rewritten to the
prefix of that path. That is what makes a member proxied across cubes follow the
route the view declares rather than a shorter one the join graph would find on
its own - see views-join-order-join-maps.test.ts.

The rewrite has no way of telling which of the view's entries a hint came from,
so it also caught the cube the same view includes at its root: every bare
boards hint was moved onto locations.boards, locations became the root of
the join tree, and boards was marked multiplied.

What changed

Views now also record the cubes they include at the root of their join tree -
the entries whose join_path is a single segment - and a bare hint into one of
those is left alone instead of being moved onto a longer path of the same view.
A hint that already sits at the head of a path is unaffected, so the route of a
member declared under a longer path does not change.

Both planners derive the rewrite from the same view metadata and both needed the
fix: with only the JS half, the leaf measures and the multiplied flag came out
right but Tesseract still built the locations LEFT JOIN boards keys subquery
and still refused the rollup.

How it was verified

  • New view-independent-join-path-roots.test.ts covers the shape end to end:
    leaf measure paths, the multiplied flag, and the rollup actually being used.
  • New Tesseract tests cover the join hints collected for each of the view's
    members, and the SQL and pre-aggregation the query plans to.
  • Both sides also cover the counterpart - a calculated measure declared under
    the longer path, whose components do yield bare hints, still walking that path
    and still fanning out.
  • Every new test was confirmed to fail with the fix reverted.
  • cargo test -p cubesqlplanner: 1378 passed.
  • cubejs-schema-compiler unit suite: 955 passed, with only the pre-existing
    error-reporter ANSI-colour snapshot failures, which fail on master too.
  • cubejs-schema-compiler Postgres integration suite against a real database
    with a locally built native planner: 51/51 suites, 577 tests.

Risks

  • The minimal schema in the issue - two independent roots and no dotted path -
    does not reproduce on current master; it resolves correctly and uses the
    rollup. The reporter's real model almost certainly also declares a path into
    the same cube, which is the shape fixed here. Worth confirming with them.
  • A view that exposes the same cube measure twice, once at its root and once
    under a longer path, and is queried for both aliases at once now plans as two
    join trees. The outer projection identifies a measure by its leaf, so both
    aliases read the same group and the other one is computed and discarded. That
    is the planner's measure-identity model rather than something this change can
    settle locally - two aliases of one cube measure are one measure, so one of
    the two contradictory answers has to lose. Before this change the multiplied
    group won for both; now the root group does.
  • fallback_hints_for_measure in multi_fact_join_groups.rs still derives a
    view's roots from the declared paths alone, so a hint-less member expression
    on a view with two unrelated roots still resolves to one of them instead of
    raising the "don't share a single root" error it has for that case. Left alone
    on purpose: it is a pre-existing gap in a different feature, and closing it
    turns queries that answer today into hard errors.

🤖 Generated with Claude Code

waralexrom and others added 3 commits September 9, 2026 12:58
A view can declare a cube twice: once at the root of its join tree
(`join_path: boards`) and once as the tail of a longer path
(`join_path: locations.boards`) for another member. A query over the
members of the independent root alone must be planned against that root.

Adds coverage on both planners for the shape and for its counterpart -
the member declared under the longer path still walking it:

- a schema-compiler unit test on leaf measure paths, multiplied measures
  and rollup matching;
- Tesseract tests on the join hints collected for each of the view's
  members, and on the SQL and pre-aggregation the query plans to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s own longer paths

A view records the join path of every cube it includes through more than
one segment, and a bare join hint into any cube on such a path is
rewritten to the prefix of that path. That is what makes a member proxied
across cubes follow the route the view declares rather than a shorter one
the join graph would find on its own.

The rewrite has no way of telling which of the view's entries a hint came
from, so it also caught the cube the same view includes at the root of its
join tree. A view declaring both `join_path: boards` and
`join_path: locations.boards` had every bare `boards` hint moved onto
`locations.boards`: a query over the members of the independent root alone
was planned as if it needed the fan-out join, its leaf measures resolved
to the dotted form, it counted as having multiplied measures, and a rollup
declared on `boards` alone stopped matching - the query fell back to
scanning `locations LEFT JOIN boards`, a cube it never references.

Views now also record the cubes they include at the root of their join
tree, and a bare hint into one of those is left alone instead of being
moved onto a longer path of the same view. A hint that already sits at the
head of a path is unaffected, so the route of a member declared under a
longer path does not change.

Both planners derive the rewrite from the same view metadata and both
needed the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root cubes were pooled across every view the query touches, so one
view's root cube could keep another view's join path from being applied
to a bare hint. They now sit in the same per-view bucket as that view's
join paths, which is how Tesseract already carries them.

Also makes the tests for the other side of the rule discriminating. They
covered a dimension proxied straight from the cube, which never yields a
bare hint for the rule to act on, so they held either way. They now cover
a calculated measure declared under the longer path, whose components do
yield bare hints, and assert the hints and the resolved leaves rather than
the presence of a table name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@waralexrom
waralexrom requested a review from a team as a code owner September 9, 2026 11:18
@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Sep 9, 2026
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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


Review complete — no blockers; 1 medium, 3 low, posted inline

Full review
  • Gather context (fetch base, read diff)
  • Review JS side (CubeSymbols.ts, BaseQuery.js)
  • Review Rust side (cube_symbol.rs, join_hints_collector.rs, bridge)
  • Review tests & fixtures
  • Post review

Verdict

The change is sound and the two halves agree. CubeSymbols.prepareIncludes splits the same loop it already used for joinMap, so rootCubes costs nothing extra and is computed exactly once (the type === 'dimensions' branch). ViewJoinMap is a good move — it turns a bare Vec<Vec<String>> threaded through six join_map() accessors into a type that owns the lookup rule, and the JS and Rust versions of that rule now line up condition for condition. The Option<Vec<String>> bridge field degrades to "no roots" for a stale JS side, which is the right default.

I traced the ordering semantics on both sides and they match: JS iterates per view then per path with an early return, Rust's path_to is already scoped to one view's map, so the outcomes are identical. The pre-existing "multiple views in one query" caveat is unchanged.

Findings

# Sev Where Issue
1 Medium join_hints_collector.rs:108-111 The iter_mut rewrite bypasses the redundancy rule JoinHints::push enforces, so a bare hint is now retained directly after a Vector ending in the same cube. That adds a candidate root to JoinGraph.buildJoin. Harmless in this fixture; a tie-break in a bidirectionally-joinable model. Suggest re-normalising through push.
2 Low view_independent_join_roots.yaml The index > 0 half of the guard is untested — removing it passes every test in the PR. One extra collect_join_hints(kpi.count) assertion closes it.
3 Low BaseQuery.js:578-599 Residual order-sensitivity: a cube that heads a path but has no single-segment entry still gets rewritten or not depending on the order the view lists its entries. Pre-existing, but it's the same situation the fix addresses with the declaration implicit.
4 Low cube_symbol.rs:92-102 The four-line inline comment in path_to restates the doc comment directly above it. Also notes the two divergent definitions of "root of the view" (ViewJoinMap vs fallback_hints_for_measure).

Notes, no action needed

  • Security / perf: nothing. No untrusted input, no new queries; queryJoinMap runs once per collectJoinHints and the Set it builds is view-sized. The nested view×path loop has the same complexity as the flattened version.
  • Docs: correctly none — this is a planner bug fix with no user-facing surface.
  • clippy::ptr_arg would normally flag path_to(&self, cube_name: &String), but &String params are pervasive in this crate and Cargo.toml runs clippy per-rule rather than wholesale, so this is consistent.
  • Split views (isSplitView) never receive joinMap either, so rootCubes inherits that pre-existing gap rather than widening it.
  • rootCubes can hold duplicates if a view lists the same cube twice under the same single-segment path. Both consumers dedupe into a set, so it's cosmetic.
  • I could not run the suites here — node_modules is not installed in this checkout and a Rust build was out of budget. The findings above are from reading the code; the PR reports both suites green.
· branch `cube-view-independent-join-path-root`

Comment on lines +108 to 111
JoinHintItem::Single(cube_name) => {
if let Some(path) = join_map.path_to(cube_name) {
*hint = JoinHintItem::Vector(path.to_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.

This rewrite pass mutates through iter_mut(), so it bypasses the redundancy rule JoinHints::push enforces — join_hints.rs:34-38 explicitly drops a Single(name) that directly follows a Vector(v) with v.last() == name. Leaving the bare hint in place is exactly what now produces that shape, and it is a new shape rather than a pre-existing one.

Concretely, kpi.scrap_pct in the new fixture. Before this change the bare hint was rewritten to the same vector and collapsed; after it, test_join_hints_view_dotted_path_measure asserts two items: [["locations","boards"], "boards"]. The "boards" entry is redundant against the vector that already ends in it, and it is not inert — JoinGraph.buildJoin (JoinGraph.ts:186) tries every hint as a candidate root and keeps the tree with the fewest joins. So this adds boards as a candidate root for any query whose only boards member is declared under the dotted path.

In this fixture the candidate loses (nothing joins boardslocations, so that root can't cover the other hint) and the integration test confirms the plan is unchanged. But in a model where the two cubes are joinable both ways, the extra root becomes a tie-break the query didn't previously have.

Suggest re-normalising after the pass — rebuild via JoinHints::push instead of from_items, so a bare hint that is the tail of the preceding path is dropped the same way it would have been at collection time. The JS half has the same shape (enrichHintsWithJoinMap feeding R.uniq, which won't collapse 'boards' against ['locations','boards']); view-independent-join-path-roots.test.ts:168 pins the three-hint result, so both sides would need the same treatment.

Comment on lines +92 to +102
/// The prefix of a declared join path that ends at `cube_name`, to be used
/// in place of a bare hint into that cube. `None` when no declared path
/// leads there, or when the view reaches the cube at its root as well.
pub fn path_to(&self, cube_name: &String) -> Option<&[String]> {
for path in self.paths.iter() {
if let Some(index) = path.iter().position(|part| part == cube_name) {
// A cube the view also includes at the root of its join tree is
// reachable on its own, so a bare hint into it must not be moved
// onto a longer path - that path serves the members included
// under it.
if index > 0 && self.root_cubes.contains(cube_name) {

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 doc comment on path_to already states the rule ("None when … the view reaches the cube at its root as well"), and the four-line inline comment below restates it in longer form. One of the two carries the meaning; the inline one is the redundant copy. Same text also appears verbatim in BaseQuery.js:592-595, where it is load-bearing since there's no doc comment there.

Separate, non-blocking: ViewJoinMap defines "root of the view" as "declared with a single-segment join_path", while fallback_hints_for_measure (multi_fact_join_groups.rs:570-578) derives roots as "path heads that no other path reaches" and ignores root_cubes entirely. Two definitions of the same concept, neither aware of the other. The PR body explains why the second is left alone, which is fair, but a one-line pointer from one to the other would stop the next reader from assuming they agree.

Comment on lines 578 to 599

return hints.map(hint => {
if (Array.isArray(hint)) {
return hint;
}

for (const path of allPaths) {
const hintIndex = path.indexOf(hint);
if (hintIndex !== -1) {
return path.slice(0, hintIndex + 1);
for (const { paths, rootCubes } of views) {
for (const path of paths) {
const hintIndex = path.indexOf(hint);
if (hintIndex !== -1) {
// A cube this view also includes at the root of its join tree is
// reachable on its own, so a bare hint into it must not be moved
// onto a longer path - that path serves the members included
// under it.
if (hintIndex > 0 && rootCubes.has(hint)) {
return hint;
}
return path.slice(0, hintIndex + 1);
}
}
}

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.

Worth noting the residual order-sensitivity this leaves, since the fix removes it for one shape but not its sibling. path.indexOf(hint) takes the first path that mentions the cube, so for

- join_path: locations.boards   # listed first
  includes: [product]
- join_path: boards.tags
  includes: [tag_name]

boards heads a path but has no single-segment entry, so it isn't in rootCubes; a bare boards hint matches locations.boards at index 1 and gets rewritten. List the two entries the other way round and it matches boards.tags at index 0 and stays bare. Same model, different answer.

Not introduced here — the flattened version had it too — and the rootCubes guard actually makes the declared-root case order-independent. But if the intent is "a cube the view can reach at its root is left alone", the head-of-a-path case is the same situation with the declaration implicit rather than explicit. Fine to leave; flagging so it's a known edge rather than a surprise.

Comment on lines +65 to +78
- name: kpi
cubes:
- join_path: locations
includes:
- count
- pos
- join_path: locations.boards
includes:
- product
- scrap_pct
- join_path: boards
includes:
- yield_pct
- board_id

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.

Coverage gap: nothing pins the index > 0 half of the guard.

With this view, root_cubes is {locations, boards} and paths is [[locations, boards]]. Delete index > 0 from path_to (and from enrichHintsWithJoinMap) and the only behaviour that changes is the locations hint: it stops becoming Vector(["locations"]) and stays Single("locations"). No test in this PR asserts the hints for kpi.count or kpi.pos, so the guard can be removed silently.

Cheap fix: a test_join_hints_view_root_measure asserting collect_join_hints(kpi.count).items() == [v(&["locations"])], alongside the four new cases. Worth having given the Single vs single-element Vector distinction is exactly what the comment above raises about candidate roots.

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.66%. Comparing base (007e565) to head (1e7f424).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11817      +/-   ##
==========================================
- Coverage   84.67%   84.66%   -0.01%     
==========================================
  Files         261      261              
  Lines       86648    86648              
==========================================
- Hits        73366    73362       -4     
- Misses      13282    13286       +4     
Flag Coverage Δ
cubesql 84.66% <ø> (-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Calculated measure via a view's independent join_path root still routes through the view's other root cube (breaks pre-aggregation matching)

1 participant