Skip to content

fix(schema-compiler): rollup_join cube hints from time dimensions - #11784

Open
waralexrom wants to merge 1 commit into
masterfrom
cube-rollup-join-time-dimension-hints
Open

fix(schema-compiler): rollup_join cube hints from time dimensions#11784
waralexrom wants to merge 1 commit into
masterfrom
cube-rollup-join-time-dimension-hints

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

A rollup_join whose only reference to one of its cubes is that cube's time_dimension could never be built: it was always rejected with Nothing to join in rollup join. Cube hints for the join tree were collected from the pre-aggregation's measures and dimensions only, never from its timeDimensions, so the cube reached solely through a time dimension was missing from the tree.

Reported alongside #11679 (this is not that issue's main claim, which does not reproduce — it is a separate limitation the reporter mentions in passing).

Changes

  • cubesHintsFromPreAggregation now also collects hints from references.timeDimensions. They are appended last, so the existing hint order — and with it the join tree root — is unchanged, and R.uniq absorbs cubes already covered by a measure or a dimension.
  • New unit test rollup-join-time-dimension-hints.test.ts, parameterized over both planners.
  • New Rust test + fixture pinning that the native planner's own hint collection covers time dimensions.

Why there is no workaround

Adding a dimension from the anchor cube does put the hint in, but pre-aggregation matching requires the dimension set to line up, so that dimension then has to be present in every query for the rollup to match at all. The two requirements cannot both hold for this shape.

Both planners are affected

The fix is in JS, but the code is shared. Under the native SQL planner matching runs in Rust and succeeds; the pre-aggregation description — and with it the join tree — is still built in JS, so the same rejection surfaced through findPreAggregationForQueryRust:

at PreAggregations.buildRollupJoin
at ... getRollupPreAggregationByName
at ... getPreAggregationByName
at buildSqlAndParams (../cubejs-backend-native/js/index.ts:464)
at PostgresQuery.findPreAggregationForQueryRust

The native planner's own hint collection already chains time_dimensions (and segments) and had no gap. The Rust test added here is a regression guard for that side; it was verified to actually catch the gap by temporarily dropping the .chain(time_dimensions…), which makes it fail with the same error.

This fix also brings the JS hint set in line with the native one: measures, dimensions, segments and time dimensions (segments already arrive inside references.dimensions).

Testing

  • New unit test: 4/4 pass; all 4 fail without the fix.
  • cargo test -p cubesqlplanner --lib: 1340 passed, 0 failed. cargo fmt --check clean.
  • Full dist/test/unit: 911 passed. The 2 failures are error-reporter.test.ts snapshots, which fail identically on master — unrelated.
  • Postgres integration: pre-aggregations.test.js 47 passed / 7 skipped (every rollup join* / rollupJoin* case green, including rollup join existing joins, which exercises the existingJoins subtraction this change also feeds); pre-aggregations-calculated-measures.test.js 4 passed.
  • eslint clean on the touched files.

Correctness of the existingJoins side

cubesHintsFromPreAggregation serves two roles inside buildRollupJoin: hints for the target rollup_join (which produce targetJoins) and hints for each leg rollup (which produce existingJoins, subtracted from the target). Leg rollups now contribute more hints too, so a leg could cancel a join it did not before — but that is correct: if a leg rollup stores a time dimension from another cube, it really was built across that join, and the edge genuinely does not need joining again. This mirrors what the native planner already does, and rollup join existing joins covers the branch.

Follow-up (not in this PR)

preAggObjForJoin still resolves join members against p.references.dimensions only. A model whose leg rollup declares the join key as its time_dimension rather than a dimension now reaches that filter and fails with a misleading "No rollups found … you may have forgotten to specify the full dimension paths". Not a regression — before this change the same model failed earlier, at "Nothing to join" — but it is the adjacent half of the same gap.

`rollup_join` derives its join tree from cube hints collected off the
pre-aggregation's own references, and `cubesHintsFromPreAggregation` read
only `measures` and `dimensions`. A `rollup_join` whose single reference to
one of its cubes is that cube's `time_dimension` therefore produced no hint
for it: the join tree covered one cube, `targetJoins` came back empty and
the pre-aggregation was rejected with "Nothing to join in rollup join".

Naming a dimension of that cube is not a workaround. It puts the hint in,
but the rollup then only matches queries that request that dimension, and
this shape cannot satisfy both requirements at once.

Collect hints from `timeDimensions` as well. They are appended last, so the
existing hint order - and with it the join tree root - is unchanged, and
`R.uniq` absorbs cubes already covered by a measure or a dimension. This
also lines the hint set up with the one the native SQL planner uses:
measures, dimensions, segments and time dimensions.

Both planners are affected. Under the native planner matching runs in Rust,
but the pre-aggregation description - and with it the join tree - is still
built here, so the same rejection surfaced through
`findPreAggregationForQueryRust`. The native planner's own hint collection
already covers time dimensions; a test pins that.

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

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 26s —— View job


Review: sound one-line fix, 0 high / 1 medium / 3 low — 3 inline comments posted

Full review
  • Read the diff
  • Review PreAggregations.ts change
  • Review JS test
  • Review Rust test + fixture
  • Post review

Verdict

The diagnosis is right and the fix is minimal. cubesHintsFromPreAggregation (PreAggregations.ts:1139) fed joinTreeForHints, and a cube reachable only via time_dimension produced no hint, so targetJoins came back empty and buildRollupJoin threw at line 1071. Adding references.timeDimensions closes the gap and lines the JS hint set up with the native planner's (segments already arrive inside references.dimensions via evaluatePreAggregationReferences). Appending last keeps hint order stable, and R.uniq collapses duplicates — the reasoning in the PR body checks out against the code.

No security or performance concerns: pure in-memory member-path collection on an already-cached path (buildRollupJoin runs inside query.cacheValue).

Findings

Sev Where Issue
Medium test/unit/rollup-join-time-dimension-hints.test.ts:118 The existingJoins half of the change is untested at the unit level — both legs here have same-cube time dimensions, so their hint sets don't move. A leg rollup with a joined time_dimension can now cancel a target join and surface Nothing to join in rollup join on a model that built before. I agree with the PR's argument that this cancellation is correct, but it's a behavior change with no unit pin.
Low src/adapter/PreAggregations.ts:1143 || [] is dead — timeDimensions is required in PreAggregationReferences and read unguarded elsewhere.
Low test/unit/rollup-join-time-dimension-hints.test.ts:3-15 13-line header comment restates the PR description; only the first sentence is load-bearing.
Low same file, assertions toContain on a concatenated loadSql string is a weak signal — an unmatched pre-agg yields '' and a misleading failure.

One claim to double-check

The PR body says "all 4 fail without the fix", but the withAnchorDimension fixture is exactly the shape the body describes as putting the hint in (dimensions: [locations.board_id] on the rollup_join → hint for locations from references.dimensions), and the query requests that dimension so matching should succeed. That case looks like it passes on master — a non-regression guard rather than a fix test, which is still worth having, just worth restating accurately.

Rust side

The added test_compile_rollup_join_cube_reached_only_by_time_dimension is a regression guard for hint collection that already chains time_dimensions; the PR notes it was verified to fail when that chain is dropped. Assertions on join.items.len(), both Single names and the root are specific enough to be meaningful. Fixture mirrors the JS model, which makes the two sides easy to keep in sync.

The acknowledged preAggObjForJoin follow-up (line 1093 resolving join members against references.dimensions only) is the right call to leave out of this PR — but the new failure mode it exposes is user-facing and misleading, so a tracking issue would be worth opening.

Verification caveat

node_modules is not installed in this checkout, so I could not run the Jest suite or cargo test — all findings above are from reading the code. The PR's own test results are the only execution evidence.

• branch `cube-rollup-join-time-dimension-hints`

preAggObj.references.measures.concat(
preAggObj.references.dimensions
preAggObj.references.dimensions,
(preAggObj.references.timeDimensions || []).map(td => td.dimension)

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.

references.timeDimensions is non-optional in PreAggregationReferences (CubeEvaluator.ts:152) and evaluatePreAggregationReferences always sets it; other call sites read it unguarded (e.g. mergePartitionTimeDimensions at PreAggregations.ts:1388). The || [] is dead defensiveness that suggests the field can be missing.

Suggested change
(preAggObj.references.timeDimensions || []).map(td => td.dimension)
preAggObj.references.timeDimensions.map(td => td.dimension)

Comment on lines +3 to +15

// `rollup_join` builds its join tree from cube hints collected off the
// pre-aggregation's own references. Those hints have to cover every cube the
// join is meant to span, including a cube whose single reference is its
// `time_dimension` - otherwise the join tree covers one cube, `targetJoins`
// comes back empty and the pre-aggregation is rejected with "Nothing to join in
// rollup join".
//
// The shape has no workaround: naming a dimension of that cube would put the
// hint in, but then that dimension has to be requested by every query for the
// rollup to match at all.
//
// Both planners build the pre-aggregation description here, so both are covered.

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 13-line header re-tells the PR description (the "no workaround" paragraph and the "both planners" note are commit-message material, not something a later editor needs in order to avoid reintroducing the bug). The load-bearing sentence is the first one; the rest can go. The two per-fixture one-liners below already say what each shape exercises.

Comment on lines +118 to +128
granularity: 'day',
dateRange: ['2026-01-01', '2026-01-31'],
}];

it('builds the join when a cube is referenced only by time_dimension', async () => {
const sql = await preAggregationSql(useNativeSqlPlanner, timeDimensionOnly, {
measures: ['boards.good_count'],
timeDimensions,
});

expect(sql).toContain('locations_locations_rollup');

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.

Neither test covers the other consumer of cubesHintsFromPreAggregation that this change also feeds: existingJoins (PreAggregations.ts:1058-1062). Both leg rollups here declare a same-cube time dimension, so their hint sets are unchanged by the fix. A leg rollup whose time_dimension is reached through a join now contributes an extra hint, which can cancel a target join and turn a previously-buildable rollup_join into Nothing to join in rollup join. The PR argues that cancellation is semantically right, and I agree — but it is a behavior change for existing models with no test pinning it. Worth adding one leg-side case (or explicitly noting that rollup join existing joins in the Postgres integration suite is the only coverage).

Also, the assertions only check that both leg table names appear somewhere in the concatenated loadSql; asserting on the number of descriptions or on the join SQL would fail more informatively if the rollup stops matching (an empty description array currently yields '' and a toContain failure that reads like a SQL mismatch).

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.67%. Comparing base (9237c26) to head (7ae23ca).
⚠️ Report is 7 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11784      +/-   ##
==========================================
- Coverage   84.67%   84.67%   -0.01%     
==========================================
  Files         261      261              
  Lines       86451    86451              
==========================================
- Hits        73204    73199       -5     
- Misses      13247    13252       +5     
Flag Coverage Δ
cubesql 84.67% <ø> (-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.

1 participant