diff --git a/.github/workflows/drivers-tests.yml b/.github/workflows/drivers-tests.yml index ad5c5e6f42702..1d52d56fceaa3 100644 --- a/.github/workflows/drivers-tests.yml +++ b/.github/workflows/drivers-tests.yml @@ -27,9 +27,9 @@ on: - 'packages/cubejs-snowflake-driver/**' - 'packages/cubejs-vertica-driver/**' - # To test SQL API Push down - 'packages/cubejs-backend-native/**' - 'rust/cubesql/**' + - 'rust/cubesqlplanner/**' pull_request: paths: - '.github/workflows/drivers-tests.yml' @@ -54,9 +54,9 @@ on: - 'packages/cubejs-snowflake-driver/**' - 'packages/cubejs-vertica-driver/**' - # To test SQL API Push down - 'packages/cubejs-backend-native/**' - 'rust/cubesql/**' + - 'rust/cubesqlplanner/**' workflow_dispatch: inputs: use_tesseract_sql_planner: diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/compiled_pre_aggregation.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/compiled_pre_aggregation.rs index 39ad35db4b53b..7b5426c85494a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/compiled_pre_aggregation.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/compiled_pre_aggregation.rs @@ -46,7 +46,7 @@ pub struct CompiledPreAggregation { pub external: Option, pub measures: Vec>, pub dimensions: Vec>, - pub time_dimensions: Vec<(Rc, Option)>, + pub time_dimensions: Vec>, pub allow_non_strict_date_range_match: bool, } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/dimension_matcher.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/dimension_matcher.rs index 4e41ca335c43b..04303521873ba 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/dimension_matcher.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/dimension_matcher.rs @@ -34,7 +34,7 @@ pub struct DimensionMatcher<'a> { query_tools: Rc, pre_aggregation: &'a CompiledPreAggregation, pre_aggregation_dimensions: HashMap, - pre_aggregation_time_dimensions: HashMap, bool)>, + pre_aggregation_time_dimensions: HashMap>, bool)>, result: MatchState, } @@ -48,7 +48,13 @@ impl<'a> DimensionMatcher<'a> { let pre_aggregation_time_dimensions = pre_aggregation .time_dimensions .iter() - .map(|(dim, granularity)| (dim.full_name(), (granularity.clone(), false))) + .map(|dim| { + if let Ok(td) = dim.as_time_dimension() { + (td.base_symbol().full_name(), (Some(td), false)) + } else { + (dim.full_name(), (None, false)) + } + }) .collect::>(); Self { query_tools, @@ -194,6 +200,7 @@ impl<'a> DimensionMatcher<'a> { time_dimension.rollup_granularity(self.query_tools.clone())? }; let base_symbol_name = time_dimension.base_symbol().full_name(); + if let Some(found) = self .pre_aggregation_time_dimensions .get_mut(&base_symbol_name) @@ -201,23 +208,31 @@ impl<'a> DimensionMatcher<'a> { if add_to_matched_dimension { found.1 = true; } - let pre_aggr_granularity = &found.0; - if granularity.is_none() || pre_aggr_granularity == &granularity { + + let pre_agg_td = &found.0; + let pre_aggr_granularity = if let Some(pre_agg_td) = pre_agg_td { + pre_agg_td.granularity().clone() + } else { + None + }; + + if granularity.is_none() || pre_aggr_granularity == granularity { Ok(MatchState::Full) - } else if pre_aggr_granularity.is_none() - || GranularityHelper::is_predefined_granularity( - pre_aggr_granularity.as_ref().unwrap(), - ) - { - let min_granularity = - GranularityHelper::min_granularity(&granularity, &pre_aggr_granularity)?; - if &min_granularity == pre_aggr_granularity { + } else if pre_aggr_granularity.is_none() { + Ok(MatchState::NotMatched) + } else if let Some(pre_agg_td) = pre_agg_td { + let min_granularity = GranularityHelper::min_granularity_for_time_dimensions( + (&granularity, time_dimension), + (&pre_aggr_granularity, &pre_agg_td), + )?; + + if min_granularity == pre_aggr_granularity { Ok(MatchState::Partial) } else { Ok(MatchState::NotMatched) } } else { - Ok(MatchState::NotMatched) //TODO Custom granularities!!! + Ok(MatchState::NotMatched) } } else { if time_dimension.owned_by_cube() { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/optimizer.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/optimizer.rs index 40b1fee63f8d7..61df891a662bb 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/optimizer.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/optimizer.rs @@ -387,12 +387,7 @@ impl PreAggregationOptimizer { .dimensions .iter() .cloned() - .chain( - pre_aggregation - .time_dimensions - .iter() - .map(|(d, _)| d.clone()), - ) + .chain(pre_aggregation.time_dimensions.iter().map(|d| d.clone())) .collect(), measures: pre_aggregation.measures.to_vec(), multiplied_measures: HashSet::new(), diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/pre_aggregations_compiler.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/pre_aggregations_compiler.rs index 2282aaa168a79..0f886824bab02 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/pre_aggregations_compiler.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/pre_aggregations_compiler.rs @@ -12,6 +12,8 @@ use crate::planner::planners::ResolvedJoinItem; use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::collectors::collect_cube_names_from_symbols; use crate::planner::sql_evaluator::MemberSymbol; +use crate::planner::sql_evaluator::TimeDimensionSymbol; +use crate::planner::GranularityHelper; use cubenativeutils::CubeError; use itertools::Itertools; use std::collections::HashMap; @@ -136,7 +138,30 @@ impl PreAggregationsCompiler { refs, Self::check_is_time_dimension, )?; - vec![(dims[0].clone(), static_data.granularity.clone())] + + if static_data.granularity.is_some() { + let evaluator_compiler_cell = self.query_tools.evaluator_compiler().clone(); + let mut evaluator_compiler = evaluator_compiler_cell.borrow_mut(); + let base_symbol = dims[0].clone(); + + let granularity_obj = GranularityHelper::make_granularity_obj( + self.query_tools.cube_evaluator().clone(), + &mut evaluator_compiler, + &base_symbol.cube_name(), + &base_symbol.name(), + static_data.granularity.clone(), + )?; + let symbol = MemberSymbol::new_time_dimension(TimeDimensionSymbol::new( + base_symbol, + static_data.granularity.clone(), + granularity_obj, + None, + )); + + vec![symbol] + } else { + vec![dims[0].clone()] + } } else { Vec::new() }; @@ -276,14 +301,16 @@ impl PreAggregationsCompiler { } fn match_time_dimensions( - a: &Vec<(Rc, Option)>, - b: &Vec<(Rc, Option)>, + a: &Vec>, + b: &Vec>, ) -> Result<(), CubeError> { - if !a - .iter() - .zip(b.iter()) - .all(|(a, b)| a.0.name() == b.0.name() && a.1 == b.1) - { + if !a.iter().zip(b.iter()).all(|(a, b)| { + if let (Ok(td_a), Ok(td_b)) = (a.as_time_dimension(), b.as_time_dimension()) { + td_a.name() == td_a.name() && td_a.granularity() == td_b.granularity() + } else { + false + } + }) { return Err(CubeError::user(format!( "Names for pre-aggregation symbols in lambda pre-aggragation don't match" ))); diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/pre_aggregation.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/pre_aggregation.rs index 43a6d4d607a72..a0b24d49206a9 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/pre_aggregation.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/pre_aggregation.rs @@ -14,7 +14,7 @@ pub struct PreAggregation { #[builder(default)] dimensions: Vec>, #[builder(default)] - time_dimensions: Vec<(Rc, Option)>, + time_dimensions: Vec>, external: bool, #[builder(default)] granularity: Option, @@ -39,7 +39,7 @@ impl PreAggregation { &self.dimensions } - pub fn time_dimensions(&self) -> &Vec<(Rc, Option)> { + pub fn time_dimensions(&self) -> &Vec> { &self.time_dimensions } @@ -97,11 +97,11 @@ impl PreAggregation { QualifiedColumnName::new(None, alias.clone()), ); } - for (dim, granularity) in self.time_dimensions().iter() { - let base_symbol = if let Ok(td) = dim.as_time_dimension() { - td.base_symbol().clone() + for dim in self.time_dimensions().iter() { + let (base_symbol, granularity) = if let Ok(td) = dim.as_time_dimension() { + (td.base_symbol().clone(), td.granularity().clone()) } else { - dim.clone() + (dim.clone(), None) }; let suffix = if let Some(granularity) = &granularity { format!("_{}", granularity.clone()) @@ -110,7 +110,7 @@ impl PreAggregation { }; let alias = format!("{}{}", base_symbol.alias(), suffix); res.insert( - dim.full_name(), + base_symbol.full_name(), QualifiedColumnName::new(None, alias.clone()), ); } @@ -184,11 +184,7 @@ impl PrettyPrint for PreAggregation { &self .time_dimensions() .iter() - .map(|(d, granularity)| format!( - "({} {})", - d.full_name(), - granularity.clone().unwrap_or("None".to_string()) - )) + .map(|d| d.full_name()) .join(", ") ), &state, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/pre_aggregation.rs b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/pre_aggregation.rs index 6ccdc671c2f47..25aa0e16dc8bd 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/pre_aggregation.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/pre_aggregation.rs @@ -95,18 +95,25 @@ impl PreAggregationProcessor<'_> { Some(alias), ); } - for (dim, granularity) in pre_aggregation.time_dimensions().iter() { + for dim in pre_aggregation.time_dimensions().iter() { + let (alias, granularity) = if let Ok(td) = dim.as_time_dimension() { + (td.base_symbol().alias(), td.granularity().clone()) + } else { + (dim.alias(), None) + }; + let name_in_table = PlanSqlTemplates::memeber_alias_name( &item.cube_alias, &dim.name(), - granularity, + &granularity, ); + let suffix = if let Some(granularity) = granularity { format!("_{}", granularity.clone()) } else { "_day".to_string() }; - let alias = format!("{}{}", dim.alias(), suffix); + let alias = format!("{}{}", alias, suffix); select_builder.add_projection_reference_member( &dim, QualifiedColumnName::new(None, name_in_table.clone()), diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index 3501c49c1f781..9c6b8b3789491 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -340,13 +340,32 @@ impl DimensionSymbolFactory { full_name: &String, cube_evaluator: Rc, ) -> Result { - let mut iter = cube_evaluator - .parse_path("dimensions".to_string(), full_name.clone())? - .into_iter(); + let parts: Vec<&str> = full_name.split('.').collect(); + let mut iter; + let member_short_path; + + // try_new might be invoked with next full_name variants: + // 1. "cube.member" + // 2. "cube.member.granularity" might come from multistage things + // 3. "cube.cube.cube...cube.member" might come from pre-agg references (as it include full join paths) + // And we can not distinguish between "cube.member.granularity" and "cube.cube.member" here, + // so we have to try-catch 2 variants of evaluation. + if let Ok(iter_by_start) = + cube_evaluator.parse_path("dimensions".to_string(), full_name.clone()) + { + member_short_path = full_name.clone(); + iter = iter_by_start.into_iter(); + } else { + member_short_path = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]); + iter = cube_evaluator + .parse_path("dimensions".to_string(), member_short_path.clone())? + .into_iter(); + } + let cube_name = iter.next().unwrap(); let name = iter.next().unwrap(); let granularity = iter.next(); - let definition = cube_evaluator.dimension_by_path(full_name.clone())?; + let definition = cube_evaluator.dimension_by_path(member_short_path)?; Ok(Self { cube_name, name, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs index 4b4570b92fe89..2b5d275d1942f 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs @@ -511,12 +511,19 @@ impl MeasureSymbolFactory { full_name: &String, cube_evaluator: Rc, ) -> Result { + let parts: Vec<&str> = full_name.split('.').collect(); + let member_short_path = if parts.len() > 2 { + format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]) + } else { + full_name.clone() + }; + let mut iter = cube_evaluator - .parse_path("measures".to_string(), full_name.clone())? + .parse_path("measures".to_string(), member_short_path.clone())? .into_iter(); let cube_name = iter.next().unwrap(); let name = iter.next().unwrap(); - let definition = cube_evaluator.measure_by_path(full_name.clone())?; + let definition = cube_evaluator.measure_by_path(member_short_path)?; let sql = definition.sql()?; Ok(Self { cube_name, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs index 30c48aa13039e..e8638fe85bfe8 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs @@ -238,13 +238,38 @@ impl TimeDimensionSymbol { query_tools: Rc, ) -> Result, CubeError> { if let Some(granularity_obj) = &self.granularity_obj { - let date_range_granularity = self.date_range_granularity(query_tools.clone())?; - let self_granularity = granularity_obj.min_granularity()?; - - GranularityHelper::min_granularity(&date_range_granularity, &self_granularity) + if let Some(date_range) = &self.date_range { + let date_range_granularity = self.date_range_granularity(query_tools.clone())?; + + // For predefined granularities or custom granularities not aligned with date range, + // we need to return the minimum granularity + if granularity_obj.is_predefined_granularity() { + let self_granularity = granularity_obj.min_granularity()?; + GranularityHelper::min_granularity(&date_range_granularity, &self_granularity) + } else { + let from_date_str = QueryDateTimeHelper::format_from_date(&date_range.0, 3)?; + let to_date_str = QueryDateTimeHelper::format_to_date(&date_range.1, 3)?; + let is_aligned = granularity_obj.is_aligned_with_date_range( + &from_date_str, + &to_date_str, + query_tools.timezone(), + )?; + + if is_aligned { + Ok(self.granularity.clone()) + } else { + let self_granularity = granularity_obj.min_granularity()?; + GranularityHelper::min_granularity( + &date_range_granularity, + &self_granularity, + ) + } + } + } else { + Ok(self.granularity.clone()) + } } else { let date_range_granularity = self.date_range_granularity(query_tools.clone())?; - Ok(date_range_granularity) } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs index 040b5ea7c52d7..d615c3f48e644 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs @@ -64,6 +64,20 @@ impl QueryDateTime { } pub fn add_interval(&self, interval: &SqlInterval) -> Result { + // For time-only intervals (hour, minute, second), use UTC arithmetic to avoid DST issues + let is_time_only = + interval.year == 0 && interval.month == 0 && interval.week == 0 && interval.day == 0; + + if is_time_only { + // Use UTC-based arithmetic for time intervals + let duration = Duration::hours(interval.hour as i64) + + Duration::minutes(interval.minute as i64) + + Duration::seconds(interval.second as i64); + let new_datetime = self.date_time + duration; + return Ok(Self::new(new_datetime)); + } + + // For date-based intervals, use local time arithmetic let date = self.naive_local().date(); // Step 1: add years and months with fallback logic @@ -239,6 +253,19 @@ mod tests { } #[test] fn test_add_interval() { + let tz = "America/Los_Angeles".parse::().unwrap(); + + let date = QueryDateTime::from_date_str(tz, "2024-03-10T03:00:00").unwrap(); + let interval = "1 hour".parse::().unwrap(); + let result = date.add_interval(&interval).unwrap().naive_utc(); + assert_eq!( + result, + NaiveDate::from_ymd_opt(2024, 3, 10) + .unwrap() + .and_hms_opt(11, 0, 0) + .unwrap() + ); + let tz = "Etc/GMT-3".parse::().unwrap(); let date = QueryDateTime::from_date_str(tz, "2024-11-03 01:30:00").unwrap(); @@ -391,5 +418,18 @@ mod tests { .and_hms_opt(0, 0, 0) .unwrap() ); + + let tz = "America/Los_Angeles".parse::().unwrap(); + let date = QueryDateTime::from_date_str(tz, "2017-01-01T00:10:00").unwrap(); + let interval = "1 hour".parse::().unwrap(); + let origin = QueryDateTime::from_date_str(tz, "2025-01-01T00:10:00").unwrap(); + let result = date.align_to_origin(&origin, &interval).unwrap(); + assert_eq!( + result.naive_local(), + NaiveDate::from_ymd_opt(2017, 1, 1) + .unwrap() + .and_hms_opt(0, 10, 0) + .unwrap() + ); } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs index 3775d24788e9f..c03f3a3feb0c7 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs @@ -185,4 +185,38 @@ impl Granularity { Ok(res) } + + /// Check if the granularity is aligned with the given date range. + /// For custom granularities, this checks if: + /// 1. The date range duration is an exact multiple of the granularity interval + /// 2. The start date is aligned with the granularity origin + pub fn is_aligned_with_date_range( + &self, + start_str: &str, + end_str: &str, + timezone: Tz, + ) -> Result { + let start = QueryDateTime::from_date_str(timezone, start_str)?; + let end = QueryDateTime::from_date_str(timezone, end_str)?; + let end = end.add_duration(chrono::Duration::milliseconds(1))?; + + // Check if the start is aligned with the origin first + let aligned_start = self.align_date_to_origin(start.clone())?; + + if start != aligned_start { + return Ok(false); + } + + // Check if the interval fits exactly into the date range + let mut test_date = start; + while test_date < end { + test_date = test_date.add_interval(&self.granularity_interval)?; + } + + if test_date != end { + return Ok(false); + } + + Ok(true) + } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs index 6ae5cdf1411db..a3ecc4c37efae 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs @@ -63,6 +63,7 @@ impl GranularityHelper { .fold(first, |acc, d| -> Result<_, CubeError> { match acc { Ok(min_dim) => { + // TODO: Add support for custom granularities comparison let min_granularity = Self::min_granularity( &min_dim.resolved_granularity()?, &d.resolved_granularity()?, @@ -251,4 +252,88 @@ impl GranularityHelper { }; Ok(granularity_obj) } + + // Returns the granularity hierarchy for a td granularity. + // Note: for custom granularities, returns [...standard_hierarchy_for_min_granularity, granularity_name]. + // custom granularity is at the end of the array, in BaseQuery.js it's first. + pub fn time_dimension_granularity_hierarchy( + time_dimension: (&Option, &TimeDimensionSymbol), + ) -> Result, CubeError> { + let granularity = time_dimension.0.clone(); + + if let Some(granularity_name) = granularity { + if Self::is_predefined_granularity(&granularity_name) { + Ok(Self::granularity_parents(&granularity_name)?.clone()) + } else { + if let Some(granularity_obj) = time_dimension.1.granularity_obj() { + let min_granularity = granularity_obj.min_granularity()?; + + if let Some(min_gran) = min_granularity { + let mut standard_hierarchy = Self::granularity_parents(&min_gran)?.clone(); + let custom = vec![granularity_name.clone()]; + standard_hierarchy.extend(custom.clone()); + Ok(standard_hierarchy) + } else { + // Safeguard: if no min_granularity, just return the custom granularity name + Ok(vec![granularity_name.clone()]) + } + } else { + // No granularity object but has a name - shouldn't happen, but handle gracefully + Err(CubeError::internal(format!( + "Time dimension has granularity '{}' but no granularity object", + granularity_name + ))) + } + } + } else { + Err(CubeError::internal(format!( + "Time dimension \"{}\" has no granularity specified", + time_dimension.1.full_name() + ))) + } + } + + pub fn min_granularity_for_time_dimensions( + time_dimension_a: (&Option, &TimeDimensionSymbol), + time_dimension_b: (&Option, &TimeDimensionSymbol), + ) -> Result, CubeError> { + let granularity_a = time_dimension_a.0; + let granularity_b = time_dimension_b.0; + + if let (Some(gran_a), Some(gran_b)) = (granularity_a.clone(), granularity_b.clone()) { + let a_hierarchy = Self::time_dimension_granularity_hierarchy(time_dimension_a)?; + let b_hierarchy = Self::time_dimension_granularity_hierarchy(time_dimension_b)?; + + let diff_position = a_hierarchy + .iter() + .zip(b_hierarchy.iter()) + .find_position(|(a, b)| a != b); + + if let Some((diff_position, _)) = diff_position { + if diff_position == 0 { + Err(CubeError::user(format!( + "Can't find common parent for '{}' and '{}'", + gran_a, gran_b + ))) + } else { + // Return the granularity before the first difference + Ok(Some(a_hierarchy[diff_position - 1].clone())) + } + } else { + // One hierarchy is a prefix of the other or they are identical + // Return the last element of the shorter hierarchy + if a_hierarchy.len() >= b_hierarchy.len() { + Ok(Some(b_hierarchy.last().unwrap().clone())) + } else { + Ok(Some(a_hierarchy.last().unwrap().clone())) + } + } + } else if granularity_a.is_some() { + Ok(granularity_a.clone()) + } else if granularity_b.is_some() { + Ok(granularity_b.clone()) + } else { + Ok(None) + } + } }