@@ -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 ) ]
7286pub struct MeasuresJoinHints {
7387 base_hints : JoinHints ,
7488 measure_hints : Vec < MeasureJoinHints > ,
89+ hints_by_cube : HashMap < String , JoinHints > ,
7590}
7691
7792impl 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
0 commit comments