Skip to content

Commit 9715bf1

Browse files
authored
fix(tesseract): Resolve join for hint-less member expressions on views (#11501)
1 parent 9587d10 commit 9715bf1

10 files changed

Lines changed: 674 additions & 42 deletions

rust/cube/cubesqlplanner/cubesqlplanner/src/planner/join_hints.rs

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
use crate::cube_bridge::join_hints::JoinHintItem;
22

3-
/// Ordered list of cube-join hints. Adjacent redundant entries are
4-
/// silently dropped on `push` / `extend` — a `Single` is skipped when
5-
/// it duplicates either the previous `Single` or the tail of the
6-
/// previous `Vector`.
3+
/// Ordered list of cube-join hints. `push` / `extend` drop an entry that
4+
/// is redundant against the one before it — an item repeating the previous
5+
/// one verbatim, or a `Single` duplicating either the previous `Single` or
6+
/// the tail of the previous `Vector`.
7+
///
8+
/// That is a local rule, not a normal form. `from_items` stores what it is
9+
/// given as-is, and nothing collapses a `Vector` that is a strict prefix of
10+
/// another (`[V[customers], V[customers, orders]]`) or a repeat that is not
11+
/// adjacent. So two hint lists that resolve to the same join tree can still
12+
/// differ — which matters, since `JoinHints` is a join tree cache key:
13+
/// equal hints hit the same entry, but unequal ones are not proof of
14+
/// different trees.
715
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
816
pub struct JoinHints {
917
items: Vec<JoinHintItem>,
@@ -19,13 +27,12 @@ impl JoinHints {
1927
}
2028

2129
pub fn push(&mut self, item: JoinHintItem) {
22-
if let JoinHintItem::Single(ref name) = item {
23-
if let Some(last) = self.items.last() {
24-
let redundant = match last {
25-
JoinHintItem::Single(s) => s == name,
26-
JoinHintItem::Vector(v) => v.last() == Some(name),
27-
};
28-
if redundant {
30+
if let Some(last) = self.items.last() {
31+
if last == &item {
32+
return;
33+
}
34+
if let (JoinHintItem::Single(name), JoinHintItem::Vector(v)) = (&item, last) {
35+
if v.last() == Some(name) {
2936
return;
3037
}
3138
}
@@ -161,6 +168,28 @@ mod tests {
161168
assert_eq!(hints.len(), 3, "Different Single is added");
162169
}
163170

171+
#[test]
172+
fn test_push_skips_repeated_vector() {
173+
let mut hints = JoinHints::new();
174+
hints.push(v(&["customers", "orders"]));
175+
hints.push(v(&["customers", "orders"]));
176+
assert_eq!(
177+
hints.len(),
178+
1,
179+
"Vector repeating the previous one is skipped"
180+
);
181+
182+
hints.push(v(&["customers", "returns"]));
183+
assert_eq!(hints.len(), 2, "Different Vector is added");
184+
185+
hints.push(v(&["customers", "orders"]));
186+
assert_eq!(
187+
hints.len(),
188+
3,
189+
"Only adjacent repeats are dropped, not every earlier occurrence"
190+
);
191+
}
192+
164193
#[test]
165194
fn test_into_items_and_into_iter() {
166195
let hints = JoinHints::from_items(vec![s("b"), s("a"), v(&["x", "y"])]);

rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs

Lines changed: 168 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl MeasuresJoinHintsBuilder {
5757
base_hints.extend(&collect_join_hints(sym)?);
5858
}
5959

60-
MeasuresJoinHints::from_base_hints(base_hints, measures)
60+
MeasuresJoinHints::from_base_hints(base_hints, measures, None)
6161
}
6262
}
6363

@@ -68,10 +68,25 @@ impl MeasuresJoinHintsBuilder {
6868
/// - `measure_hints` — per-measure incremental hints, one entry per
6969
/// non-multi-stage measure. Multi-stage measures plan their joins
7070
/// separately and are skipped here.
71+
/// - `hints_by_cube` — the hints the measures of the whole query collected,
72+
/// grouped by the cube the measure itself belongs to, which for a measure of a
73+
/// view is that view. Multi-stage measures are included. Only used to resolve a
74+
/// measure that carries no hints of its own and sits on a view (see
75+
/// `MultiFactJoinGroups::fallback_hints_for_measure`), which is why the
76+
/// grouping matters: such a measure may only borrow from members of its own
77+
/// view. It is inherited as-is when regrouping over a measure subset, so the
78+
/// measure stays in the join tree of the query it came from.
79+
///
80+
/// Dimensions, filters and query-level join hints are deliberately absent: they
81+
/// land in `base_hints`, so a measure of a query that has any of them never
82+
/// reaches the fallback in the first place. That also means the view grouping
83+
/// only guards the case where `base_hints` is empty - a dimension of an
84+
/// unrelated view still pulls a hint-less member expression into its join.
7185
#[derive(Clone, Debug)]
7286
pub struct MeasuresJoinHints {
7387
base_hints: JoinHints,
7488
measure_hints: Vec<MeasureJoinHints>,
89+
hints_by_cube: HashMap<String, JoinHints>,
7590
}
7691

7792
impl MeasuresJoinHints {
@@ -85,37 +100,59 @@ impl MeasuresJoinHints {
85100
}
86101

87102
/// Reuse the existing `base_hints` to produce a new
88-
/// `MeasuresJoinHints` over a different measure subset.
103+
/// `MeasuresJoinHints` over a different measure subset. `hints_by_cube`
104+
/// keeps describing the whole query, not the subset.
89105
pub fn for_measures(&self, measures: &[Rc<MemberSymbol>]) -> Result<Self, CubeError> {
90-
Self::from_base_hints(self.base_hints.clone(), measures)
106+
Self::from_base_hints(
107+
self.base_hints.clone(),
108+
measures,
109+
Some(self.hints_by_cube.clone()),
110+
)
91111
}
92112

113+
/// `inherited_hints_by_cube` describes the whole query these measures were
114+
/// taken from, so the measures add nothing to it; without it they are grouped
115+
/// from scratch.
93116
fn from_base_hints(
94117
base_hints: JoinHints,
95118
measures: &[Rc<MemberSymbol>],
119+
inherited_hints_by_cube: Option<HashMap<String, JoinHints>>,
96120
) -> Result<Self, CubeError> {
97-
let mut filtered_measures = Vec::new();
121+
let inherited = inherited_hints_by_cube.is_some();
122+
let mut hints_by_cube = inherited_hints_by_cube.unwrap_or_default();
123+
124+
let mut measure_hints: Vec<MeasureJoinHints> = Vec::new();
98125
for m in measures {
99-
if !has_multi_stage_members(m, true)? {
100-
filtered_measures.push(m.clone());
126+
// Multi-stage measures plan their joins separately, so they get no
127+
// entry of their own - but their hints still count towards their
128+
// cube's. With inherited hints there is nothing left to collect
129+
// them for.
130+
let is_multi_stage = has_multi_stage_members(m, true)?;
131+
if is_multi_stage && inherited {
132+
continue;
133+
}
134+
let own_hints = collect_join_hints(m)?;
135+
if !inherited {
136+
hints_by_cube
137+
.entry(m.cube_name())
138+
.or_insert_with(JoinHints::new)
139+
.extend(&own_hints);
101140
}
141+
if is_multi_stage {
142+
continue;
143+
}
144+
let mut hints = base_hints.clone();
145+
hints.extend(&own_hints);
146+
measure_hints.push(MeasureJoinHints {
147+
measure: m.clone(),
148+
hints,
149+
});
102150
}
103151

104-
let measure_hints: Vec<MeasureJoinHints> = filtered_measures
105-
.iter()
106-
.map(|m| -> Result<_, CubeError> {
107-
let mut hints = base_hints.clone();
108-
hints.extend(&collect_join_hints(m)?);
109-
Ok(MeasureJoinHints {
110-
measure: m.clone(),
111-
hints,
112-
})
113-
})
114-
.collect::<Result<Vec<_>, _>>()?;
115-
116152
Ok(Self {
117153
base_hints,
118154
measure_hints,
155+
hints_by_cube,
119156
})
120157
}
121158

@@ -209,10 +246,20 @@ impl MultiFactJoinGroups {
209246
.iter()
210247
.map(|mh| -> Result<_, CubeError> {
211248
let measure_hints = if mh.hints.is_empty() {
212-
Self::fallback_hints_for_measure(query_tools, &mh.measure)?
249+
Self::fallback_hints_for_measure(query_tools, &mh.measure, hints)?
213250
} else {
214251
mh.hints.clone()
215252
};
253+
if measure_hints.is_empty() {
254+
return Err(CubeError::user(format!(
255+
"Can't resolve the cube to query for '{}': the member references no \
256+
members of '{}', and neither the rest of the query nor the join map \
257+
of '{}' gives a cube to join from",
258+
mh.measure.full_name(),
259+
mh.measure.cube_name(),
260+
mh.measure.cube_name()
261+
)));
262+
}
216263
let (key, join_tree) = resolve(&measure_hints)?;
217264
Ok((vec![mh.measure.clone()], key, join_tree))
218265
})
@@ -237,23 +284,117 @@ impl MultiFactJoinGroups {
237284
}
238285

239286
/// Hints to use for a measure whose own hint set resolved to empty.
240-
/// Seeds the measure's owning cube when it is a real, joinable cube;
241-
/// returns empty for views (resolved via the query's other members).
287+
/// Seeds the measure's owning cube when it is a real, joinable cube.
288+
///
289+
/// A view is not a joinable cube, so it can't seed anything. Such a measure
290+
/// borrows the hints of the other members **of that same view** instead, and
291+
/// lands in the same join group as the members it borrowed from. Members of
292+
/// another view or of a bare cube are not borrowed from: their cubes need not
293+
/// appear in this view at all, and counting rows of a join tree the view is
294+
/// not built on would answer a different question than the one asked.
295+
///
296+
/// Borrowing at all is what the legacy planner does, but it borrows wider: it
297+
/// unions the join hints of every query member into one join tree, with no
298+
/// notion of which view a member came from. Narrowing that union to the
299+
/// measure's own view is the difference here.
300+
///
301+
/// When there is nothing to borrow from either, the view's own join map is
302+
/// the last resort: its paths start at the cube the view is rooted at, so
303+
/// that cube is the one to query. This is what makes a query built only from
304+
/// such member expressions, like `COUNT(*)` over a view, resolvable. It only
305+
/// covers views that have a join map at all: a view over a single directly
306+
/// joinable cube records no path, and such a query is rejected - see
307+
/// `test_expr_measure_count_star_only_member_on_view`.
308+
///
309+
/// Note that borrowing makes the meaning of such a measure depend on the rest
310+
/// of the query: `COUNT(*)` over a view with two facts counts the rows of the
311+
/// cube the view is rooted at when selected alone, and the rows of the fanned
312+
/// out join tree when selected together with measures from both facts. The
313+
/// legacy planner behaves the same way, since it pools the hints of all query
314+
/// members into one join tree.
315+
///
316+
/// Known hole, kept for legacy parity: the same-view rule only reaches
317+
/// measures. Dimensions, filters and query-level hints land in `base_hints`,
318+
/// which is not view-scoped, and a measure whose `base_hints` are non-empty
319+
/// never gets here at all. So a dimension of an *unrelated* view still drags a
320+
/// hint-less member expression into that view's join and yields a number for a
321+
/// join tree its own view is not built on - see
322+
/// `test_expr_measure_count_star_no_hints_beside_other_view_dimension`, which
323+
/// pins that behaviour. Closing it means resolving from the view bucket
324+
/// whenever the measure's *own* hints are empty, which would also make the
325+
/// ordinary shape - a view dimension next to `COUNT(*)` on the same view -
326+
/// depend on that bucket carrying dimensions, so it is a larger change than
327+
/// this fix.
242328
fn fallback_hints_for_measure(
243329
query_tools: &Rc<State>,
244330
measure: &Rc<MemberSymbol>,
331+
all_hints: &MeasuresJoinHints,
245332
) -> Result<JoinHints, CubeError> {
246333
let cube_name = measure.cube_name();
247-
let is_view = query_tools
334+
let cube_definition = query_tools
248335
.cube_evaluator()
249336
.cube_from_path(cube_name.clone())
250-
.ok()
337+
.ok();
338+
let is_view = cube_definition
339+
.as_ref()
251340
.and_then(|cube| cube.static_data().is_view)
252341
.unwrap_or(false);
253-
if is_view {
254-
Ok(JoinHints::new())
255-
} else {
256-
Ok(JoinHints::from_items(vec![JoinHintItem::Single(cube_name)]))
342+
if !is_view {
343+
return Ok(JoinHints::from_items(vec![JoinHintItem::Single(cube_name)]));
344+
}
345+
346+
match all_hints.hints_by_cube.get(&cube_name) {
347+
Some(hints) if !hints.is_empty() => return Ok(hints.clone()),
348+
_ => {}
349+
}
350+
351+
let join_map = cube_definition
352+
.and_then(|cube| cube.static_data().join_map.clone())
353+
.unwrap_or_default();
354+
if join_map.is_empty() {
355+
return Ok(JoinHints::new());
356+
}
357+
// A cube that heads one path but is reached from another one is not a
358+
// root of the view - the path it heads is just the tail of a longer walk.
359+
// Only the heads that nothing else reaches are candidates.
360+
let reached = join_map
361+
.iter()
362+
.flat_map(|path| path.iter().skip(1))
363+
.collect::<HashSet<_>>();
364+
let roots = join_map
365+
.iter()
366+
.filter_map(|path| path.first())
367+
.filter(|head| !reached.contains(*head))
368+
.unique()
369+
.collect_vec();
370+
371+
let no_single_root = |detail: String| {
372+
CubeError::user(format!(
373+
"Can't resolve the cube to query for '{}': the member references no members of \
374+
'{}', and {detail}",
375+
measure.full_name(),
376+
cube_name,
377+
))
378+
};
379+
380+
match roots.as_slice() {
381+
[root_cube] => Ok(JoinHints::from_items(vec![JoinHintItem::Single(
382+
(*root_cube).clone(),
383+
)])),
384+
// Every path of the join map is headed by a cube some other path
385+
// reaches, so the paths lead in a circle and none of them starts at
386+
// the view's root.
387+
[] => Err(no_single_root(format!(
388+
"the join paths of that view are cyclic: {}",
389+
join_map.iter().map(|path| path.join(".")).join(", ")
390+
))),
391+
// The join map is ordered by the order the view lists its cubes, so
392+
// picking one root out of several would make the answer depend on
393+
// that order with nothing to hint at it.
394+
_ => Err(no_single_root(format!(
395+
"that view is built on cubes that don't share a single root: {}",
396+
roots.iter().join(", ")
397+
))),
257398
}
258399
}
259400

rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,9 +645,38 @@ impl MockViewBuilder {
645645
}
646646
}
647647

648+
// Like the schema compiler, only multi-hop join paths land in the join
649+
// map: a direct cube needs no path to be reached. Note this is what makes
650+
// a root cube member of a view carry `Vector([cube])` rather than
651+
// `Single(cube)` - `collect_join_hints` enriches a hint into the prefix of
652+
// the path it sits on - and both forms are distinct join tree cache keys.
653+
//
654+
// The schema compiler fills the map in `CubeSymbols.prepareIncludes`,
655+
// inside the pass over `dimensions`, but it pushes the entry for an
656+
// included cube before looking at that cube's includes - so a cube
657+
// contributing no dimension still gets one. `customer_overview` includes
658+
// only measures from `customers.orders` and is mapped all the same.
659+
// Emitting one entry per view cube here matches that. What the compiler
660+
// does differently is evaluate the join path as a reference instead of
661+
// splitting the raw string, so a fixture would only diverge with a join
662+
// path that is not a literal.
663+
let join_map = self
664+
.view_cubes
665+
.iter()
666+
.map(|view_cube| {
667+
view_cube
668+
.join_path
669+
.split('.')
670+
.map(|part| part.to_string())
671+
.collect::<Vec<_>>()
672+
})
673+
.filter(|path| path.len() > 1)
674+
.collect::<Vec<_>>();
675+
648676
let view_def = MockCubeDefinition::builder()
649677
.name(self.view_name.clone())
650678
.is_view(Some(true))
679+
.join_map(Some(join_map))
651680
.default_filters(self.default_filters)
652681
.build();
653682

0 commit comments

Comments
 (0)