Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd9f415
feat(cubesql): merge view joins on shared cube member into single Cub…
cursoragent May 31, 2026
f60313e
test(cubesql): cover view join merge on shared cube member
cursoragent May 31, 2026
76963f1
test(cubesql): add group-by view join query for shared cube member
cursoragent May 31, 2026
e5f4bf1
feat(cubesql): respect inner/left/right join semantics for view joins
cursoragent May 31, 2026
a18644f
refactor(cubesql): use join key (not a measure) for view-join presenc…
cursoragent May 31, 2026
072a43f
feat(cubesql): only merge view joins when the join key is fully withi…
cursoragent May 31, 2026
b95e8d7
fix(cubesql): gate view-join test module with cfg(test); drop unused var
cursoragent Jun 1, 2026
9a682af
chore: re-trigger CI (flaky Windows native + concurrency-canceled red…
cursoragent Jun 1, 2026
6306a2f
feat(cubesql): only merge view joins under aggregate grouping by the …
cursoragent Jun 6, 2026
93e642b
feat(cubesql): gate view-join merge on the Tesseract SQL planner
cursoragent Jun 6, 2026
3b61886
docs: document multi-fact queries via SQL API view joins
cursoragent Jun 6, 2026
4872fcb
fix(cubesql): require all join-key columns on a side to share one cub…
cursoragent Jun 6, 2026
45db195
refactor(cubesql): build view-join presence filters only on successfu…
cursoragent Jun 7, 2026
cee814e
feat(cubesql): MultiFactJoinWrapper for N-way view joins and filter p…
cursoragent Jun 7, 2026
1aca9d9
docs: document N-way view joins and filter support in the SQL API
cursoragent Jun 7, 2026
144bb60
fix(cubesql): address review nits on MultiFactJoinWrapper rewrite
cursoragent Jun 7, 2026
3d11f94
feat(cubesql): support view joins on date_trunc / shared time dimensions
cursoragent Jun 7, 2026
e5e24bf
test(cubesql): cover composite-key view joins (multiple dimensions)
cursoragent Jun 7, 2026
3614128
feat(cubesql): support view joins on date_trunc combined with a dimen…
cursoragent Jun 7, 2026
379a2a1
fix(cubesql): store join-key granularity and require it to match GROU…
cursoragent Jun 8, 2026
cebb74b
docs(cubesql): require grained join for time-dimension multi-fact merge
cursoragent Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs-mintlify/docs/data-modeling/multi-fact-views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,116 @@ The combined result shows measures from each fact table side by side:
Charlie has no orders and Diana has no returns — both are still included
with `NULL` values for the missing fact table.

## Joining views in the SQL API

You don't have to define a dedicated multi-fact view to get multi-fact
behavior. The [SQL API][ref-sql-api] produces the same query when you **join
two or more views on a dimension they share** and group by that dimension.

Suppose `orders_view` and `returns_view` are two separate views that each
expose the customer's `name` (both backed by the same underlying
`customers.name` member). Joining them on `name` and grouping by it triggers a
multi-fact query:

```sql
SELECT
o.name,
MEASURE(o.total_amount),
MEASURE(r.total_refund)
FROM orders_view o
LEFT JOIN returns_view r ON r.name = o.name
GROUP BY 1
```

Cube recognizes that both `name` columns resolve to the same cube member,
merges the two view scans into a single multi-fact query, and runs it with the
separate-subquery-then-join strategy described
[above](#what-cube-does-under-the-hood).

This rewrite applies only when:

- The Tesseract SQL planner is enabled via
[`CUBEJS_TESSERACT_SQL_PLANNER`][ref-tesseract-env].
- Both sides of the join condition resolve to the **same underlying cube
member** (a shared dimension), and the join key is composed only of
dimensions.
- The query is **grouped by the join key** — every grouped dimension is the
shared join key. Ungrouped joins (such as `SELECT *`) and queries that group
by a different dimension are not merged and fall back to standard join
handling.

### Joining three or more views

The rewrite is not limited to two views. Chained joins on the same shared key
are merged into a single multi-fact query, with each view contributing its own
aggregating subquery:

```sql
SELECT
o.name,
MEASURE(o.total_amount),
MEASURE(r.total_refund),
MEASURE(p.total_paid)
FROM orders_view o
FULL JOIN returns_view r ON r.name = o.name
FULL JOIN payments_view p ON p.name = o.name
GROUP BY 1
```

### Joining on a time dimension

A common multi-fact pattern joins facts on a shared time dimension and groups by
a truncated grain. Both shapes are supported:

- **Join on the raw time column, group by `DATE_TRUNC`:**

```sql
SELECT DATE_TRUNC('day', o.created_at), MEASURE(o.total_amount), MEASURE(r.total_refund)
FROM orders_view o
LEFT JOIN returns_view r ON r.created_at = o.created_at
GROUP BY 1
```

- **Join directly on `DATE_TRUNC`:**

```sql
SELECT DATE_TRUNC('day', o.created_at), MEASURE(o.total_amount), MEASURE(r.total_refund)
FROM orders_view o
JOIN returns_view r ON DATE_TRUNC('day', r.created_at) = DATE_TRUNC('day', o.created_at)
GROUP BY 1
```

In both cases the grouped column is emitted as a time dimension with its
granularity. A join written directly on `DATE_TRUNC` is an `INNER` join (the SQL
planner expresses it as a filtered cross join), so both sides must share a key;
both truncated columns must resolve to the same underlying time member at the
same granularity.

### Filtering the join

Filters on top of the join are supported and are applied to the merged query:

- A `WHERE` clause is pushed into the merged scan. A predicate on a dimension
shared by all facts filters the whole result; a predicate on a fact-specific
dimension filters only that fact's subquery.
- A predicate in the `ON` clause that the planner can attach to a single side
(for example, a condition on the optional side of a `LEFT JOIN`) becomes a
filter on that fact. Predicates that the SQL planner can't push to one side
of an outer join (such as a left-table condition in a `LEFT JOIN ON`) aren't
supported by the planner and will raise an error.

### Join type

The facts are stitched together with a `FULL JOIN` on the shared key, and the
`JOIN` type in your SQL controls which rows are kept:

| SQL join | Result |
| --- | --- |
| `FULL [OUTER] JOIN` | every key from either view (default multi-fact behavior) |
| `INNER JOIN` | only keys present in **both** views |
| `LEFT JOIN` | every key from the left view; right-side measures are `NULL` when missing |
| `RIGHT JOIN` | every key from the right view; left-side measures are `NULL` when missing |

## Common patterns

### Time as the shared dimension
Expand Down Expand Up @@ -417,5 +527,6 @@ to that fact's subquery.
[ref-views]: /docs/data-modeling/views
[ref-view-ref]: /reference/data-modeling/view
[ref-segments]: /reference/data-modeling/segments
[ref-sql-api]: /reference/core-data-apis/sql-api
[ref-tesseract-env]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner
[link-tesseract]: https://cube.dev/blog/introducing-tesseract
28 changes: 27 additions & 1 deletion docs-mintlify/reference/core-data-apis/sql-api/joins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,33 @@ LIMIT 5;
Please note that, even if `product_description` is in the inner selection, it isn't
evaluated in the final query as it isn't used in any way.

## Joining views on a shared dimension

When you join two views on a dimension that resolves to the **same underlying
cube member** and group by that dimension, Cube doesn't perform a row-level
join. Instead it merges them into a single
[multi-fact query][ref-multi-fact-views]: each view becomes its own
aggregating subquery and the results are stitched together on the shared key,
so measures from both views are combined without fan-out.

```sql
SELECT
o.name,
MEASURE(o.total_amount),
MEASURE(r.total_refund)
FROM orders_view o
LEFT JOIN returns_view r ON r.name = o.name
GROUP BY 1
```

The `JOIN` type (`INNER`, `LEFT`, `RIGHT`, `FULL`) controls which keys are
kept. This requires the [Tesseract SQL planner][ref-tesseract-env] and only
applies to grouped queries whose `GROUP BY` is the join key. See
[multi-fact views][ref-multi-fact-views] for the full explanation.


[ref-views]: /docs/data-modeling/views
[ref-join-paths]: /docs/data-modeling/joins#join-paths
[ref-join-hints]: /docs/data-modeling/joins#join-hints
[ref-join-hints]: /docs/data-modeling/joins#join-hints
[ref-multi-fact-views]: /docs/data-modeling/multi-fact-views
[ref-tesseract-env]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner
1 change: 1 addition & 0 deletions rust/cubesql/cubesql/src/compile/rewrite/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ impl BestCubePlan {
LogicalPlanLanguage::JoinCheckStage(_) => 1,
LogicalPlanLanguage::JoinCheckPushDown(_) => 1,
LogicalPlanLanguage::JoinCheckPullUp(_) => 1,
LogicalPlanLanguage::MultiFactJoinWrapper(_) => 1,
LogicalPlanLanguage::SortProjectionPushdownReplacer(_) => 1,
LogicalPlanLanguage::SortProjectionPullupReplacer(_) => 1,
// Not really replacers but those should be deemed as mandatory rewrites and as soon as
Expand Down
14 changes: 14 additions & 0 deletions rust/cubesql/cubesql/src/compile/rewrite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,16 @@ crate::plan_to_language! {
left_input: Arc<LogicalPlan>,
right_input: Arc<LogicalPlan>,
},
// Intermediate node produced while merging a join of two (view)
// CubeScans on a shared cube member into a single multi-fact CubeScan.
// `input` is the merged CubeScan; `join_members` holds the underlying
// cube members the scans were joined on, so the aggregate finalize rule
// can verify the GROUP BY matches the join key. Rewrite-only: it must be
// eliminated (unwrapped at the aggregate) before extraction.
MultiFactJoinWrapper {
input: Arc<LogicalPlan>,
join_members: Vec<String>,
},
}
}

Expand Down Expand Up @@ -2266,6 +2276,10 @@ fn cube_scan_wrapper(input: impl Display, finalized: impl Display) -> String {
format!("(CubeScanWrapper {} {})", input, finalized)
}

fn multi_fact_join_wrapper(input: impl Display, join_members: impl Display) -> String {
format!("(MultiFactJoinWrapper {} {})", input, join_members)
}

fn distinct(input: impl Display) -> String {
format!("(Distinct {})", input)
}
Expand Down
Loading
Loading