From d49d0822a6e186896bee5aaadf4b3ad82514b1df Mon Sep 17 00:00:00 2001 From: waralexrom <108349432+waralexrom@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:46:59 +0100 Subject: [PATCH] chore(tesseract): Filters refactoring (#10530) --- .../src/planner/filter/base_filter.rs | 1061 ++--------------- .../cubesqlplanner/src/planner/filter/mod.rs | 3 + .../planner/filter/operators/comparison.rs | 39 + .../planner/filter/operators/date_range.rs | 40 + .../planner/filter/operators/date_single.rs | 53 + .../src/planner/filter/operators/equality.rs | 34 + .../filter/operators/filter_sql_context.rs | 182 +++ .../src/planner/filter/operators/in_list.rs | 35 + .../src/planner/filter/operators/like.rs | 77 ++ .../filter/operators/measure_filter.rs | 56 + .../src/planner/filter/operators/mod.rs | 13 + .../planner/filter/operators/nullability.rs | 23 + .../filter/operators/rolling_window.rs | 35 + .../operators/to_date_rolling_window.rs | 35 + .../src/planner/filter/typed_filter.rs | 413 +++++++ .../planners/multi_stage/applied_state.rs | 69 +- .../multi_stage/multi_stage_query_planner.rs | 12 +- .../symbols/common/static_filter.rs | 2 +- .../cube_bridge/mock_driver_tools.rs | 10 + .../test_fixtures/cube_bridge/mock_schema.rs | 30 + .../cube_bridge/mock_sql_templates_render.rs | 4 +- .../yaml_files/common/rolling_window.yaml | 17 + .../schemas/yaml_files/common/visitors.yaml | 3 + .../test_fixtures/test_utils/test_context.rs | 80 +- .../cubesqlplanner/src/tests/filter/mod.rs | 26 + .../src/tests/filter/partition_range.rs | 161 +++ .../cubesqlplanner/src/tests/filter/to_sql.rs | 710 +++++++++++ .../src/tests/filter/to_sql_timezone.rs | 61 + .../src/tests/filter/use_raw_values.rs | 152 +++ .../cubesqlplanner/src/tests/mod.rs | 1 + .../tests/rolling_window_sql_generation.rs | 219 +++- ...rolling_window_bounded_no_granularity.snap | 7 + ...lling_window_bounded_with_granularity.snap | 8 + ...lling_window_to_date_with_granularity.snap | 8 + ...indow_trailing_bounded_no_granularity.snap | 7 + ...dow_trailing_bounded_with_granularity.snap | 8 + 36 files changed, 2657 insertions(+), 1037 deletions(-) create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/comparison.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_range.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_single.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/equality.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/filter_sql_context.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/in_list.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/like.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/measure_filter.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/mod.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/nullability.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/rolling_window.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/to_date_rolling_window.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/filter/typed_filter.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/filter/mod.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/filter/partition_range.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql_timezone.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/filter/use_raw_values.rs create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_no_granularity.snap create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_with_granularity.snap create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_to_date_with_granularity.snap create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_no_granularity.snap create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_with_granularity.snap diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/base_filter.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/base_filter.rs index 7f1976057d3d7..00d5f7941f035 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/base_filter.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/base_filter.rs @@ -1,65 +1,49 @@ use super::filter_operator::FilterOperator; -use crate::cube_bridge::member_sql::FilterParamsColumn; -use crate::planner::query_tools::QueryTools; +use super::typed_filter::{resolve_base_symbol, TypedFilter}; use crate::planner::sql_evaluator::MemberSymbol; use crate::planner::sql_templates::PlanSqlTemplates; -use crate::planner::sql_templates::TemplateProjectionColumn; -use crate::planner::{evaluate_with_context, FiltersContext, VisitorContext}; -use crate::planner::{Granularity, GranularityHelper, QueryDateTimeHelper}; +use crate::planner::VisitorContext; use cubenativeutils::CubeError; use itertools::Itertools; use std::rc::Rc; -const FROM_PARTITION_RANGE: &str = "__FROM_PARTITION_RANGE"; - -const TO_PARTITION_RANGE: &str = "__TO_PARTITION_RANGE"; - #[derive(Debug, Clone, PartialEq, Eq)] pub enum FilterType { Dimension, Measure, } +// TODO: temporary compatibility proxy — collapse into TypedFilter and update FilterItem consumers #[derive(Clone)] pub struct BaseFilter { - query_tools: Rc, - member_evaluator: Rc, - #[allow(dead_code)] - filter_type: FilterType, - filter_operator: FilterOperator, - values: Vec>, - use_raw_values: bool, + typed_filter: TypedFilter, } impl PartialEq for BaseFilter { fn eq(&self, other: &Self) -> bool { - self.filter_type == other.filter_type - && self.filter_operator == other.filter_operator - && self.values == other.values + self.typed_filter.filter_type() == other.typed_filter.filter_type() + && self.typed_filter.operator() == other.typed_filter.operator() + && self.typed_filter.values() == other.typed_filter.values() } } impl BaseFilter { pub fn try_new( - query_tools: Rc, + query_tools: Rc, member_evaluator: Rc, filter_type: FilterType, filter_operator: FilterOperator, values: Option>>, ) -> Result, CubeError> { - let values = if let Some(values) = values { - values - } else { - vec![] - }; - Ok(Rc::new(Self { - query_tools, - member_evaluator, - filter_type, - filter_operator, - values, - use_raw_values: false, - })) + let typed_filter = TypedFilter::builder() + .query_tools(query_tools) + .member_evaluator(member_evaluator) + .filter_type(filter_type) + .operator(filter_operator) + .values(values) + .build()?; + + Ok(Rc::new(Self { typed_filter })) } pub fn change_operator( @@ -67,54 +51,62 @@ impl BaseFilter { filter_operator: FilterOperator, values: Vec>, use_raw_values: bool, - ) -> Rc { - Rc::new(Self { - query_tools: self.query_tools.clone(), - member_evaluator: self.member_evaluator.clone(), - filter_type: self.filter_type.clone(), - filter_operator, - values, - use_raw_values, - }) + ) -> Result, CubeError> { + let typed_filter = self + .typed_filter + .to_builder() + .operator(filter_operator) + .values(Some(values)) + .use_raw_values(use_raw_values) + .build()?; + + Ok(Rc::new(Self { typed_filter })) } pub fn member_evaluator(&self) -> Rc { - if let Ok(time_dimension) = self.member_evaluator.as_time_dimension() { - time_dimension.base_symbol().clone() - } else { - self.member_evaluator.clone() - } + resolve_base_symbol(self.typed_filter.member_evaluator()) } pub fn raw_member_evaluator(&self) -> Rc { - self.member_evaluator.clone() + self.typed_filter.member_evaluator().clone() } - pub fn with_member_evaluator(&self, member_evaluator: Rc) -> Rc { - let mut result = self.clone(); - result.member_evaluator = member_evaluator; - Rc::new(result) + pub fn with_member_evaluator( + &self, + member_evaluator: Rc, + ) -> Result, CubeError> { + let typed_filter = self + .typed_filter + .to_builder() + .member_evaluator(member_evaluator) + .build()?; + + Ok(Rc::new(Self { typed_filter })) } - //FIXME Not very good solution, but suitable for check time dimension filters in pre-aggregations pub fn time_dimension_symbol(&self) -> Option> { - if self.member_evaluator.as_time_dimension().is_ok() { - Some(self.member_evaluator.clone()) + if self + .typed_filter + .member_evaluator() + .as_time_dimension() + .is_ok() + { + Some(self.typed_filter.member_evaluator().clone()) } else { None } } pub fn values(&self) -> &Vec> { - &self.values + self.typed_filter.values() } pub fn filter_operator(&self) -> &FilterOperator { - &self.filter_operator + self.typed_filter.operator() } pub fn use_raw_values(&self) -> bool { - self.use_raw_values + self.typed_filter.use_raw_values() } pub fn member_name(&self) -> String { @@ -122,14 +114,22 @@ impl BaseFilter { } pub fn is_single_value_equal(&self) -> bool { - self.values.len() == 1 && self.filter_operator == FilterOperator::Equal + self.typed_filter.values().len() == 1 + && *self.typed_filter.operator() == FilterOperator::Equal } pub fn get_value_restrictions(&self) -> Option> { - if self.filter_operator == FilterOperator::In - || self.filter_operator == FilterOperator::Equal + if *self.typed_filter.operator() == FilterOperator::In + || *self.typed_filter.operator() == FilterOperator::Equal { - Some(self.values.iter().cloned().filter_map(|v| v).collect_vec()) + Some( + self.typed_filter + .values() + .iter() + .cloned() + .filter_map(|v| v) + .collect_vec(), + ) } else { None } @@ -140,934 +140,21 @@ impl BaseFilter { context: Rc, plan_templates: &PlanSqlTemplates, ) -> Result { - if matches!(self.filter_operator, FilterOperator::MeasureFilter) { - self.measure_filter_where(context, plan_templates) - } else { - let filters_context = context.filters_context(); - let symbol = self.member_evaluator(); - let member_type = match symbol.as_ref() { - MemberSymbol::Dimension(dimension_symbol) => { - Some(dimension_symbol.dimension_type().to_string()) - } - MemberSymbol::Measure(measure_symbol) => { - Some(measure_symbol.measure_type().to_string()) - } - _ => None, - }; - - if !filters_context.filter_params_columns.is_empty() { - let symbol_to_match = if let Ok(time_dim) = symbol.as_time_dimension() { - time_dim.base_symbol().clone().resolve_reference_chain() - } else { - symbol.clone().resolve_reference_chain() - }; - if let Some(filter_params_column) = filters_context - .filter_params_columns - .get(&symbol_to_match.full_name()) - { - return self.to_sql_for_filter_params( - filter_params_column, - symbol, - plan_templates, - filters_context, - &member_type, - ); - } - } - - let member_sql = evaluate_with_context(&symbol, context.clone(), plan_templates)?; - - self.to_sql_impl( - &member_sql, - symbol, - plan_templates, - filters_context, - &member_type, - ) - } - } - - fn to_sql_for_filter_params( - &self, - column: &FilterParamsColumn, - symbol: Rc, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - match column { - FilterParamsColumn::String(column) => { - self.to_sql_impl(column, symbol, plan_templates, filters_context, member_type) - } - FilterParamsColumn::Callback(filter_params_callback) => { - let args = match self.filter_operator { - FilterOperator::InDateRange | FilterOperator::NotInDateRange => { - let use_db_time_zone = !filters_context.use_local_tz; - let (from, to) = - self.allocate_date_params(use_db_time_zone, false, plan_templates)?; - vec![from, to] - } - _ => self - .values - .iter() - .filter_map(|v| v.as_ref().map(|v| self.allocate_param(&v))) - .collect::>(), - }; - filter_params_callback.call(&args) - } - } - } - - fn to_sql_impl( - &self, - member_sql: &str, - symbol: Rc, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - let res = match self.filter_operator { - FilterOperator::Equal => { - self.equals_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotEqual => { - self.not_equals_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::InDateRange => { - self.in_date_range(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::BeforeDate => { - self.before_date(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::BeforeOrOnDate => { - self.before_or_on_date(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::AfterDate => { - self.after_date(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::AfterOrOnDate => { - self.after_or_on_date(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotInDateRange => { - self.not_in_date_range(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::RegularRollingWindowDateRange => self - .regular_rolling_window_date_range( - &member_sql, - plan_templates, - filters_context, - &member_type, - )?, - FilterOperator::ToDateRollingWindowDateRange => { - let query_granularity = if self.values.len() >= 3 { - if let Some(granularity) = &self.values[2] { - granularity - } else { - return Err(CubeError::user( - "Granularity required for to_date rolling window".to_string(), - )); - } - } else { - return Err(CubeError::user( - "Granularity required for to_date rolling window".to_string(), - )); - }; - let evaluator_compiler_cell = self.query_tools.evaluator_compiler().clone(); - let mut evaluator_compiler = evaluator_compiler_cell.borrow_mut(); - - let Some(granularity_obj) = GranularityHelper::make_granularity_obj( - self.query_tools.cube_evaluator().clone(), - &mut evaluator_compiler, - &symbol.cube_name(), - &symbol.name(), - Some(query_granularity.clone()), - )? - else { - return Err(CubeError::internal(format!( - "Rolling window granularity '{}' is not found in time dimension '{}'", - query_granularity, - symbol.name() - ))); - }; - - self.to_date_rolling_window_date_range( - &member_sql, + let filters_context = context.filters_context(); + if !filters_context.filter_params_columns.is_empty() { + let symbol_to_match = + resolve_base_symbol(self.typed_filter.member_evaluator()).resolve_reference_chain(); + if let Some(filter_params_column) = filters_context + .filter_params_columns + .get(&symbol_to_match.full_name()) + { + return self.typed_filter.to_sql_for_filter_params( + filter_params_column, plan_templates, filters_context, - &member_type, - granularity_obj, - )? - } - FilterOperator::In => { - self.in_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotIn => { - self.not_in_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Set => { - self.set_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotSet => { - self.not_set_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Gt => { - self.gt_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Gte => { - self.gte_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Lt => { - self.lt_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Lte => { - self.lte_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::Contains => { - self.contains_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotContains => { - self.not_contains_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::StartsWith => { - self.starts_with_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotStartsWith => self.not_starts_with_where( - &member_sql, - plan_templates, - filters_context, - &member_type, - )?, - FilterOperator::EndsWith => { - self.ends_with_where(&member_sql, plan_templates, filters_context, &member_type)? - } - FilterOperator::NotEndsWith => self.not_ends_with_where( - &member_sql, - plan_templates, - filters_context, - &member_type, - )?, - FilterOperator::MeasureFilter => { - return Err(CubeError::internal(format!( - "Measure filter should be processed separately" - ))); - } - }; - Ok(res) - } - - fn measure_filter_where( - &self, - context: Rc, - plan_templates: &PlanSqlTemplates, - ) -> Result { - let res = match self.member_evaluator.as_ref() { - MemberSymbol::Measure(measure_symbol) => { - if measure_symbol.measure_filters().is_empty() - && measure_symbol.measure_drill_filters().is_empty() - { - plan_templates.always_true()? - } else { - let visitor = context.make_visitor(self.query_tools.clone()); - let node_processor = context.node_processor(); - - measure_symbol - .measure_filters() - .iter() - .chain(measure_symbol.measure_drill_filters().iter()) - .map(|filter| -> Result { - Ok(format!( - "({})", - filter.eval( - &visitor, - node_processor.clone(), - self.query_tools.clone(), - plan_templates - )? - )) - }) - .collect::, _>>()? - .join(" AND ") - } - } - _ => plan_templates.always_true()?, - }; - Ok(res) - } - - fn equals_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - let need_null_check = self.is_need_null_chek(false); - if self.is_array_value() { - plan_templates.in_where( - member_sql.to_string(), - self.filter_cast_and_allocate_values(member_type, plan_templates)?, - need_null_check, - ) - } else if self.does_values_contain_null() { - plan_templates.not_set_where(member_sql.to_string()) - } else { - plan_templates.equals( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - need_null_check, - ) - } - } - - fn not_equals_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - let need_null_check = self.is_need_null_chek(true); - if self.is_array_value() { - plan_templates.not_in_where( - member_sql.to_string(), - self.filter_cast_and_allocate_values(member_type, plan_templates)?, - need_null_check, - ) - } else if self.does_values_contain_null() { - plan_templates.set_where(member_sql.to_string()) - } else { - plan_templates.not_equals( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - need_null_check, - ) - } - } - - fn in_date_range( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let (from, to) = self.allocate_date_params(use_db_time_zone, false, plan_templates)?; - plan_templates.time_range_filter(member_sql.to_string(), from, to) - } - - fn not_in_date_range( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let (from, to) = self.allocate_date_params(use_db_time_zone, false, plan_templates)?; - plan_templates.time_not_in_range_filter(member_sql.to_string(), from, to) - } - - fn before_date( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let value = self.first_timestamp_param(use_db_time_zone, false, plan_templates)?; - - plan_templates.lt(member_sql.to_string(), value) - } - - fn before_or_on_date( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let value = - self.first_timestamp_param_as_to_date(use_db_time_zone, false, plan_templates)?; - - plan_templates.lte(member_sql.to_string(), value) - } - - fn after_date( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let value = - self.first_timestamp_param_as_to_date(use_db_time_zone, false, plan_templates)?; - - plan_templates.gt(member_sql.to_string(), value) - } - - fn after_or_on_date( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let use_db_time_zone = !filters_context.use_local_tz; - let value = self.first_timestamp_param(use_db_time_zone, false, plan_templates)?; - - plan_templates.gte(member_sql.to_string(), value) - } - - fn extend_date_range_bound( - &self, - date: String, - interval: &Option, - is_sub: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result, CubeError> { - if let Some(interval) = interval { - if interval != "unbounded" { - if is_sub { - Ok(Some( - plan_templates.subtract_interval(date, interval.clone())?, - )) - } else { - Ok(Some(plan_templates.add_interval(date, interval.clone())?)) - } - } else { - Ok(None) + ); } - } else { - Ok(Some(date.to_string())) } - } - - fn date_range_from_time_series( - &self, - plan_templates: &PlanSqlTemplates, - ) -> Result<(String, String), CubeError> { - let from_expr = format!("min({})", plan_templates.quote_identifier("date_from")?); - let to_expr = format!("max({})", plan_templates.quote_identifier("date_to")?); - let from_expr = plan_templates.series_bounds_cast(&from_expr)?; - let to_expr = plan_templates.series_bounds_cast(&to_expr)?; - let alias = format!("value"); - let time_series_cte_name = format!("time_series"); // FIXME May be should be passed as parameter - - let from_column = TemplateProjectionColumn { - expr: from_expr.clone(), - alias: alias.clone(), - aliased: plan_templates.column_aliased(&from_expr, &alias)?, - }; - - let to_column = TemplateProjectionColumn { - expr: to_expr.clone(), - alias: alias.clone(), - aliased: plan_templates.column_aliased(&to_expr, &alias)?, - }; - let from = plan_templates.select( - vec![], - &time_series_cte_name, - vec![from_column], - None, - vec![], - None, - vec![], - None, - None, - false, - )?; - let to = plan_templates.select( - vec![], - &time_series_cte_name, - vec![to_column], - None, - vec![], - None, - vec![], - None, - None, - false, - )?; - Ok((format!("({})", from), format!("({})", to))) - } - - fn regular_rolling_window_date_range( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - let (from, to) = self.date_range_from_time_series(plan_templates)?; - - let from = if self.values.len() >= 3 { - self.extend_date_range_bound(from, &self.values[2], true, plan_templates)? - } else { - Some(from) - }; - - let to = if self.values.len() >= 4 { - self.extend_date_range_bound(to, &self.values[3], false, plan_templates)? - } else { - Some(to) - }; - - let date_field = plan_templates.convert_tz(member_sql.to_string())?; - if let (Some(from), Some(to)) = (&from, &to) { - plan_templates.time_range_filter(date_field, from.clone(), to.clone()) - } else if let Some(from) = &from { - plan_templates.gte(date_field, from.clone()) - } else if let Some(to) = &to { - plan_templates.lte(date_field, to.clone()) - } else { - plan_templates.always_true() - } - } - - fn to_date_rolling_window_date_range( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - _member_type: &Option, - granularity_obj: Granularity, - ) -> Result { - let (from, to) = self.date_range_from_time_series(plan_templates)?; - - let from = granularity_obj.apply_to_input_sql(plan_templates, from.clone())?; - - let date_field = plan_templates.convert_tz(member_sql.to_string())?; - plan_templates.time_range_filter(date_field, from, to) - } - - fn in_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - let need_null_check = self.is_need_null_chek(false); - plan_templates.in_where( - member_sql.to_string(), - self.filter_cast_and_allocate_values(member_type, plan_templates)?, - need_null_check, - ) - } - - fn not_in_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - let need_null_check = self.is_need_null_chek(true); - plan_templates.not_in_where( - member_sql.to_string(), - self.filter_cast_and_allocate_values(member_type, plan_templates)?, - need_null_check, - ) - } - - fn set_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - plan_templates.set_where(member_sql.to_string()) - } - - fn not_set_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - _member_type: &Option, - ) -> Result { - plan_templates.not_set_where(member_sql.to_string()) - } - - fn gt_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - plan_templates.gt( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - ) - } - - fn gte_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - plan_templates.gte( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - ) - } - - fn lt_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - plan_templates.lt( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - ) - } - - fn lte_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - plan_templates.lte( - member_sql.to_string(), - self.first_param(member_type, plan_templates)?, - ) - } - - fn contains_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, false, true, true, plan_templates, member_type) - } - - fn not_contains_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, true, true, true, plan_templates, member_type) - } - - fn starts_with_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, false, false, true, plan_templates, member_type) - } - - fn not_starts_with_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, true, false, true, plan_templates, member_type) - } - - fn ends_with_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, false, true, false, plan_templates, member_type) - } - - fn not_ends_with_where( - &self, - member_sql: &str, - plan_templates: &PlanSqlTemplates, - _filters_context: &FiltersContext, - member_type: &Option, - ) -> Result { - self.like_or_where(member_sql, true, true, false, plan_templates, member_type) - } - - fn like_or_where( - &self, - member_sql: &str, - not: bool, - start_wild: bool, - end_wild: bool, - plan_templates: &PlanSqlTemplates, - member_type: &Option, - ) -> Result { - let values = self.filter_cast_and_allocate_values(member_type, plan_templates)?; - let like_parts = values - .into_iter() - .map(|v| plan_templates.ilike(member_sql, &v, start_wild, end_wild, not)) - .collect::, _>>()?; - let logical_symbol = if not { " AND " } else { " OR " }; - let null_check = if self.is_need_null_chek(not) { - plan_templates.or_is_null_check(member_sql.to_string())? - } else { - "".to_string() - }; - Ok(format!( - "({}){}", - like_parts.join(logical_symbol), - null_check - )) - } - - fn from_date_in_db_time_zone( - &self, - value: &String, - use_db_time_zone: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.use_raw_values { - return Ok(value.clone()); - } - let from = self.format_from_date(value, plan_templates)?; - - let res = if use_db_time_zone && from != FROM_PARTITION_RANGE { - plan_templates.in_db_time_zone(from)? - } else { - from - }; - Ok(res) - } - - fn to_date_in_db_time_zone( - &self, - value: &String, - use_db_time_zone: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.use_raw_values { - return Ok(value.clone()); - } - let from = self.format_to_date(value, plan_templates)?; - - let res = if use_db_time_zone && from != TO_PARTITION_RANGE { - plan_templates.in_db_time_zone(from)? - } else { - from - }; - Ok(res) - } - - fn allocate_date_params( - &self, - use_db_time_zone: bool, - as_date_time: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result<(String, String), CubeError> { - if self.values.len() >= 2 { - let from = if let Some(from_str) = &self.values[0] { - self.from_date_in_db_time_zone(from_str, use_db_time_zone, plan_templates)? - } else { - return Err(CubeError::user(format!( - "Arguments for date range is not valid" - ))); - }; - - let to = if let Some(to_str) = &self.values[1] { - self.to_date_in_db_time_zone(to_str, use_db_time_zone, plan_templates)? - } else { - return Err(CubeError::user(format!( - "Arguments for date range is not valid" - ))); - }; - let from = self.allocate_timestamp_param(&from, as_date_time, plan_templates)?; - let to = self.allocate_timestamp_param(&to, as_date_time, plan_templates)?; - Ok((from, to)) - } else { - Err(CubeError::user(format!( - "2 arguments expected for date range, got {}", - self.values.len() - ))) - } - } - - fn format_from_date( - &self, - date: &str, - plan_templates: &PlanSqlTemplates, - ) -> Result { - QueryDateTimeHelper::format_from_date(date, plan_templates.timestamp_precision()?) - } - - fn format_to_date( - &self, - date: &str, - plan_templates: &PlanSqlTemplates, - ) -> Result { - QueryDateTimeHelper::format_to_date(date, plan_templates.timestamp_precision()?) - } - - fn allocate_param(&self, param: &str) -> String { - self.query_tools.allocate_param(param) - } - - fn allocate_timestamp_param( - &self, - param: &str, - as_date_time: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.use_raw_values { - return Ok(param.to_string()); - } - let placeholder = self.query_tools.allocate_param(param); - if as_date_time { - plan_templates.date_time_cast(placeholder) - } else { - plan_templates.time_stamp_cast(placeholder) - } - } - - fn first_param( - &self, - member_type: &Option, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.values.is_empty() { - Err(CubeError::user(format!( - "Expected one parameter but nothing found" - ))) - } else { - if let Some(value) = &self.values[0] { - self.cast_param(member_type, self.allocate_param(value), plan_templates) - } else { - Ok("NULL".to_string()) - } - } - } - - fn cast_param( - &self, - member_type: &Option, - value: String, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if let Some(member_type) = member_type { - let member_sql = match member_type.as_str() { - "boolean" => plan_templates.bool_param_cast(&value)?, - "number" => plan_templates.number_param_cast(&value)?, - _ => value.clone(), - }; - Ok(member_sql) - } else { - Ok(value.clone()) - } - } - - fn first_timestamp_param( - &self, - use_db_time_zone: bool, - as_date_time: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.values.is_empty() { - Err(CubeError::user(format!( - "Expected at least one parameter but nothing found" - ))) - } else { - if let Some(value) = &self.values[0] { - self.allocate_timestamp_param( - &self.from_date_in_db_time_zone(value, use_db_time_zone, plan_templates)?, - as_date_time, - plan_templates, - ) - } else { - Err(CubeError::user(format!( - "Arguments for timestamp parameter for operator {} is not valid", - self.filter_operator().to_string() - ))) - } - } - } - - fn first_timestamp_param_as_to_date( - &self, - use_db_time_zone: bool, - as_date_time: bool, - plan_templates: &PlanSqlTemplates, - ) -> Result { - if self.values.is_empty() { - Err(CubeError::user(format!( - "Expected at least one parameter but nothing found" - ))) - } else { - if let Some(value) = &self.values[0] { - self.allocate_timestamp_param( - &self.to_date_in_db_time_zone(value, use_db_time_zone, plan_templates)?, - as_date_time, - plan_templates, - ) - } else { - Err(CubeError::user(format!( - "Arguments for timestamp parameter for operator {} is not valid", - self.filter_operator().to_string() - ))) - } - } - } - - fn is_need_null_chek(&self, is_not: bool) -> bool { - let contains_null = self.does_values_contain_null(); - if is_not { - !contains_null - } else { - contains_null - } - } - - fn does_values_contain_null(&self) -> bool { - self.values.iter().any(|v| v.is_none()) - } - - fn is_array_value(&self) -> bool { - self.values.len() > 1 - } - - fn filter_cast_and_allocate_values( - &self, - member_type: &Option, - plan_templates: &PlanSqlTemplates, - ) -> Result, CubeError> { - let map_fn: Box Result> = - if let Some(member_type) = member_type { - match member_type.as_str() { - "boolean" => Box::new(|s| plan_templates.bool_param_cast(&s)), - "number" => Box::new(|s| plan_templates.number_param_cast(&s)), - _ => Box::new(|s| Ok(s)), - } - } else { - Box::new(|s| Ok(s)) - }; - - let res = self - .values - .iter() - .filter_map(|v| v.as_ref().map(|v| self.allocate_param(&v))) - .map(|s| map_fn(s)) - .collect::, _>>()?; - Ok(res) + self.typed_filter.to_sql(context, plan_templates) } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/mod.rs index 94f97c19d1813..4825c52bb8abc 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/mod.rs @@ -2,7 +2,10 @@ pub mod base_filter; pub mod base_segment; pub mod compiler; pub mod filter_operator; +mod operators; +pub mod typed_filter; pub use base_filter::BaseFilter; pub use base_segment::BaseSegment; pub use filter_operator::FilterOperator; +pub use typed_filter::resolve_base_symbol; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/comparison.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/comparison.rs new file mode 100644 index 0000000000000..fc077fb09fdec --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/comparison.rs @@ -0,0 +1,39 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub enum ComparisonKind { + Gt, + Gte, + Lt, + Lte, +} + +#[derive(Clone, Debug)] +pub struct ComparisonOp { + kind: ComparisonKind, + value: String, + member_type: Option, +} + +impl ComparisonOp { + pub fn new(kind: ComparisonKind, value: String, member_type: Option) -> Self { + Self { + kind, + value, + member_type, + } + } +} + +impl FilterOperationSql for ComparisonOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let param = ctx.allocate_and_cast(&self.value, &self.member_type)?; + match self.kind { + ComparisonKind::Gt => ctx.plan_templates.gt(ctx.member_sql.to_string(), param), + ComparisonKind::Gte => ctx.plan_templates.gte(ctx.member_sql.to_string(), param), + ComparisonKind::Lt => ctx.plan_templates.lt(ctx.member_sql.to_string(), param), + ComparisonKind::Lte => ctx.plan_templates.lte(ctx.member_sql.to_string(), param), + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_range.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_range.rs new file mode 100644 index 0000000000000..9aa395daefae5 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_range.rs @@ -0,0 +1,40 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub enum DateRangeKind { + InRange, + NotInRange, +} + +#[derive(Clone, Debug)] +pub struct DateRangeOp { + kind: DateRangeKind, + from: String, + to: String, +} + +impl DateRangeOp { + pub fn new(kind: DateRangeKind, from: String, to: String) -> Self { + Self { kind, from, to } + } +} + +impl FilterOperationSql for DateRangeOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let from_param = ctx.format_and_allocate_from_date(&self.from)?; + let to_param = ctx.format_and_allocate_to_date(&self.to)?; + match self.kind { + DateRangeKind::InRange => ctx.plan_templates.time_range_filter( + ctx.member_sql.to_string(), + from_param, + to_param, + ), + DateRangeKind::NotInRange => ctx.plan_templates.time_not_in_range_filter( + ctx.member_sql.to_string(), + from_param, + to_param, + ), + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_single.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_single.rs new file mode 100644 index 0000000000000..76b2c735f3b06 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/date_single.rs @@ -0,0 +1,53 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub enum DateSingleKind { + Before, + BeforeOrOn, + After, + AfterOrOn, +} + +#[derive(Clone, Debug)] +pub struct DateSingleOp { + kind: DateSingleKind, + value: String, +} + +impl DateSingleOp { + pub fn new(kind: DateSingleKind, value: String) -> Self { + Self { kind, value } + } +} + +impl FilterOperationSql for DateSingleOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + match self.kind { + DateSingleKind::Before | DateSingleKind::AfterOrOn => { + let param = ctx.format_and_allocate_from_date(&self.value)?; + match self.kind { + DateSingleKind::Before => { + ctx.plan_templates.lt(ctx.member_sql.to_string(), param) + } + DateSingleKind::AfterOrOn => { + ctx.plan_templates.gte(ctx.member_sql.to_string(), param) + } + _ => unreachable!(), + } + } + DateSingleKind::BeforeOrOn | DateSingleKind::After => { + let param = ctx.format_and_allocate_to_date(&self.value)?; + match self.kind { + DateSingleKind::BeforeOrOn => { + ctx.plan_templates.lte(ctx.member_sql.to_string(), param) + } + DateSingleKind::After => { + ctx.plan_templates.gt(ctx.member_sql.to_string(), param) + } + _ => unreachable!(), + } + } + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/equality.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/equality.rs new file mode 100644 index 0000000000000..8e2accc1ab080 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/equality.rs @@ -0,0 +1,34 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub struct EqualityOp { + negated: bool, + value: String, + member_type: Option, +} + +impl EqualityOp { + pub fn new(negated: bool, value: String, member_type: Option) -> Self { + Self { + negated, + value, + member_type, + } + } +} + +impl FilterOperationSql for EqualityOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let param = ctx.allocate_and_cast(&self.value, &self.member_type)?; + // For negated (notEquals), add OR IS NULL check when value is not null + let need_null_check = self.negated; + if self.negated { + ctx.plan_templates + .not_equals(ctx.member_sql.to_string(), param, need_null_check) + } else { + ctx.plan_templates + .equals(ctx.member_sql.to_string(), param, false) + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/filter_sql_context.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/filter_sql_context.rs new file mode 100644 index 0000000000000..3ac92edff0a0e --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/filter_sql_context.rs @@ -0,0 +1,182 @@ +use crate::planner::query_tools::QueryTools; +use crate::planner::sql_templates::{PlanSqlTemplates, TemplateProjectionColumn}; +use crate::planner::QueryDateTimeHelper; +use cubenativeutils::CubeError; +use std::rc::Rc; + +const FROM_PARTITION_RANGE: &str = "__FROM_PARTITION_RANGE"; +const TO_PARTITION_RANGE: &str = "__TO_PARTITION_RANGE"; + +pub struct FilterSqlContext<'a> { + pub member_sql: &'a str, + pub query_tools: &'a Rc, + pub plan_templates: &'a PlanSqlTemplates, + pub use_db_time_zone: bool, + pub use_raw_values: bool, +} + +impl<'a> FilterSqlContext<'a> { + pub fn allocate_param(&self, value: &str) -> String { + self.query_tools.allocate_param(value) + } + + pub fn cast_param( + &self, + value: &str, + member_type: &Option, + ) -> Result { + match member_type.as_deref() { + Some("boolean") => self.plan_templates.bool_param_cast(value), + Some("number") => self.plan_templates.number_param_cast(value), + _ => Ok(value.to_string()), + } + } + + pub fn allocate_and_cast( + &self, + value: &str, + member_type: &Option, + ) -> Result { + let allocated = self.allocate_param(value); + self.cast_param(&allocated, member_type) + } + + pub fn allocate_and_cast_values( + &self, + values: &[Option], + member_type: &Option, + ) -> Result, CubeError> { + values + .iter() + .filter_map(|v| v.as_ref()) + .map(|v| self.allocate_and_cast(v, member_type)) + .collect() + } + + pub fn allocate_timestamp_param(&self, value: &str) -> Result { + if self.use_raw_values { + return Ok(value.to_string()); + } + let placeholder = self.query_tools.allocate_param(value); + self.plan_templates.time_stamp_cast(placeholder) + } + + pub fn format_and_allocate_from_date(&self, value: &str) -> Result { + if self.use_raw_values { + return Ok(value.to_string()); + } + if self.is_partition_range(value) { + return self.allocate_timestamp_param(value); + } + let precision = self.plan_templates.timestamp_precision()?; + let formatted = QueryDateTimeHelper::format_from_date(value, precision)?; + let with_tz = self.apply_db_time_zone(formatted)?; + self.allocate_timestamp_param(&with_tz) + } + + pub fn format_and_allocate_to_date(&self, value: &str) -> Result { + if self.use_raw_values { + return Ok(value.to_string()); + } + if self.is_partition_range(value) { + return self.allocate_timestamp_param(value); + } + let precision = self.plan_templates.timestamp_precision()?; + let formatted = QueryDateTimeHelper::format_to_date(value, precision)?; + let with_tz = self.apply_db_time_zone(formatted)?; + self.allocate_timestamp_param(&with_tz) + } + + fn is_partition_range(&self, value: &str) -> bool { + value == FROM_PARTITION_RANGE || value == TO_PARTITION_RANGE + } + + fn apply_db_time_zone(&self, value: String) -> Result { + if self.use_db_time_zone { + self.plan_templates.in_db_time_zone(value) + } else { + Ok(value) + } + } + + pub fn convert_tz(&self, field: &str) -> Result { + self.plan_templates.convert_tz(field.to_string()) + } + + pub fn date_range_from_time_series(&self) -> Result<(String, String), CubeError> { + let from_expr = format!( + "min({})", + self.plan_templates.quote_identifier("date_from")? + ); + let to_expr = format!("max({})", self.plan_templates.quote_identifier("date_to")?); + let from_expr = self.plan_templates.series_bounds_cast(&from_expr)?; + let to_expr = self.plan_templates.series_bounds_cast(&to_expr)?; + let alias = "value".to_string(); + let time_series_cte_name = "time_series".to_string(); + + let from_column = TemplateProjectionColumn { + expr: from_expr.clone(), + alias: alias.clone(), + aliased: self.plan_templates.column_aliased(&from_expr, &alias)?, + }; + let to_column = TemplateProjectionColumn { + expr: to_expr.clone(), + alias: alias.clone(), + aliased: self.plan_templates.column_aliased(&to_expr, &alias)?, + }; + + let from = self.plan_templates.select( + vec![], + &time_series_cte_name, + vec![from_column], + None, + vec![], + None, + vec![], + None, + None, + false, + )?; + let to = self.plan_templates.select( + vec![], + &time_series_cte_name, + vec![to_column], + None, + vec![], + None, + vec![], + None, + None, + false, + )?; + Ok((format!("({})", from), format!("({})", to))) + } + + pub fn extend_date_range_bound( + &self, + date: String, + interval: &Option, + is_sub: bool, + ) -> Result, CubeError> { + match interval { + Some(interval) if interval != "unbounded" => { + if is_sub { + Ok(Some( + self.plan_templates + .subtract_interval(date, interval.clone())?, + )) + } else { + Ok(Some( + self.plan_templates.add_interval(date, interval.clone())?, + )) + } + } + Some(_) => Ok(None), // unbounded + None => Ok(Some(date)), + } + } +} + +pub trait FilterOperationSql { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result; +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/in_list.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/in_list.rs new file mode 100644 index 0000000000000..73d85e37cccef --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/in_list.rs @@ -0,0 +1,35 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub struct InListOp { + negated: bool, + values: Vec>, + member_type: Option, +} + +impl InListOp { + pub fn new(negated: bool, values: Vec>, member_type: Option) -> Self { + Self { + negated, + values, + member_type, + } + } +} + +impl FilterOperationSql for InListOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let has_null = self.values.iter().any(|v| v.is_none()); + let need_null_check = if self.negated { !has_null } else { has_null }; + let allocated = ctx.allocate_and_cast_values(&self.values, &self.member_type)?; + + if self.negated { + ctx.plan_templates + .not_in_where(ctx.member_sql.to_string(), allocated, need_null_check) + } else { + ctx.plan_templates + .in_where(ctx.member_sql.to_string(), allocated, need_null_check) + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/like.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/like.rs new file mode 100644 index 0000000000000..e41f0426300d7 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/like.rs @@ -0,0 +1,77 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub struct LikeOp { + negated: bool, + start_wild: bool, + end_wild: bool, + values: Vec, + has_null: bool, + member_type: Option, +} + +impl LikeOp { + pub fn new( + negated: bool, + start_wild: bool, + end_wild: bool, + values: Vec, + has_null: bool, + member_type: Option, + ) -> Self { + Self { + negated, + start_wild, + end_wild, + values, + has_null, + member_type, + } + } +} + +impl FilterOperationSql for LikeOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let allocated = ctx.allocate_and_cast_values( + &self + .values + .iter() + .map(|v| Some(v.clone())) + .collect::>(), + &self.member_type, + )?; + + let like_parts = allocated + .into_iter() + .map(|v| { + ctx.plan_templates.ilike( + ctx.member_sql, + &v, + self.start_wild, + self.end_wild, + self.negated, + ) + }) + .collect::, _>>()?; + + let logical_symbol = if self.negated { " AND " } else { " OR " }; + let need_null_check = if self.negated { + !self.has_null + } else { + self.has_null + }; + let null_check = if need_null_check { + ctx.plan_templates + .or_is_null_check(ctx.member_sql.to_string())? + } else { + "".to_string() + }; + + Ok(format!( + "({}){}", + like_parts.join(logical_symbol), + null_check + )) + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/measure_filter.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/measure_filter.rs new file mode 100644 index 0000000000000..7caadb6689e68 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/measure_filter.rs @@ -0,0 +1,56 @@ +use crate::planner::query_tools::QueryTools; +use crate::planner::sql_evaluator::MemberSymbol; +use crate::planner::sql_templates::PlanSqlTemplates; +use crate::planner::VisitorContext; +use cubenativeutils::CubeError; +use std::rc::Rc; + +#[derive(Clone, Debug)] +pub struct MeasureFilterOp; + +impl MeasureFilterOp { + pub fn new() -> Self { + Self + } + + pub fn to_sql( + &self, + member_evaluator: &Rc, + query_tools: &Rc, + context: &Rc, + plan_templates: &PlanSqlTemplates, + ) -> Result { + match member_evaluator.as_ref() { + MemberSymbol::Measure(measure_symbol) => { + if measure_symbol.measure_filters().is_empty() + && measure_symbol.measure_drill_filters().is_empty() + { + plan_templates.always_true() + } else { + let visitor = context.make_visitor(query_tools.clone()); + let node_processor = context.node_processor(); + + let parts = measure_symbol + .measure_filters() + .iter() + .chain(measure_symbol.measure_drill_filters().iter()) + .map(|filter| -> Result { + Ok(format!( + "({})", + filter.eval( + &visitor, + node_processor.clone(), + query_tools.clone(), + plan_templates, + )? + )) + }) + .collect::, _>>()?; + + Ok(parts.join(" AND ")) + } + } + _ => plan_templates.always_true(), + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/mod.rs new file mode 100644 index 0000000000000..4bc72d28e7521 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/mod.rs @@ -0,0 +1,13 @@ +pub mod comparison; +pub mod date_range; +pub mod date_single; +pub mod equality; +mod filter_sql_context; +pub mod in_list; +pub mod like; +pub mod measure_filter; +pub mod nullability; +pub mod rolling_window; +pub mod to_date_rolling_window; + +pub use filter_sql_context::{FilterOperationSql, FilterSqlContext}; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/nullability.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/nullability.rs new file mode 100644 index 0000000000000..8a3b19234bf6d --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/nullability.rs @@ -0,0 +1,23 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub struct NullabilityOp { + negated: bool, +} + +impl NullabilityOp { + pub fn new(negated: bool) -> Self { + Self { negated } + } +} + +impl FilterOperationSql for NullabilityOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + if self.negated { + ctx.plan_templates.not_set_where(ctx.member_sql.to_string()) + } else { + ctx.plan_templates.set_where(ctx.member_sql.to_string()) + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/rolling_window.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/rolling_window.rs new file mode 100644 index 0000000000000..1d2f6731fbb32 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/rolling_window.rs @@ -0,0 +1,35 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use cubenativeutils::CubeError; + +#[derive(Clone, Debug)] +pub struct RegularRollingWindowOp { + trailing: Option, + leading: Option, +} + +impl RegularRollingWindowOp { + pub fn new(trailing: Option, leading: Option) -> Self { + Self { trailing, leading } + } +} + +impl FilterOperationSql for RegularRollingWindowOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let (from, to) = ctx.date_range_from_time_series()?; + + let from = ctx.extend_date_range_bound(from, &self.trailing, true)?; + let to = ctx.extend_date_range_bound(to, &self.leading, false)?; + + let date_field = ctx.convert_tz(ctx.member_sql)?; + + match (&from, &to) { + (Some(from), Some(to)) => { + ctx.plan_templates + .time_range_filter(date_field, from.clone(), to.clone()) + } + (Some(from), None) => ctx.plan_templates.gte(date_field, from.clone()), + (None, Some(to)) => ctx.plan_templates.lte(date_field, to.clone()), + (None, None) => ctx.plan_templates.always_true(), + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/to_date_rolling_window.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/to_date_rolling_window.rs new file mode 100644 index 0000000000000..f827433f1bc49 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/operators/to_date_rolling_window.rs @@ -0,0 +1,35 @@ +use super::{FilterOperationSql, FilterSqlContext}; +use crate::planner::Granularity; +use cubenativeutils::CubeError; + +#[derive(Clone)] +pub struct ToDateRollingWindowOp { + granularity: Granularity, +} + +impl std::fmt::Debug for ToDateRollingWindowOp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToDateRollingWindowOp") + .field("granularity", &"") + .finish() + } +} + +impl ToDateRollingWindowOp { + pub fn new(granularity: Granularity) -> Self { + Self { granularity } + } +} + +impl FilterOperationSql for ToDateRollingWindowOp { + fn to_sql(&self, ctx: &FilterSqlContext) -> Result { + let (from, to) = ctx.date_range_from_time_series()?; + + let from = self + .granularity + .apply_to_input_sql(ctx.plan_templates, from)?; + + let date_field = ctx.convert_tz(ctx.member_sql)?; + ctx.plan_templates.time_range_filter(date_field, from, to) + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/typed_filter.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/typed_filter.rs new file mode 100644 index 0000000000000..5642a274e830e --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/filter/typed_filter.rs @@ -0,0 +1,413 @@ +use crate::cube_bridge::member_sql::FilterParamsColumn; +use crate::planner::query_tools::QueryTools; +use crate::planner::sql_evaluator::MemberSymbol; +use crate::planner::sql_templates::PlanSqlTemplates; +use crate::planner::{evaluate_with_context, FiltersContext, VisitorContext}; +use cubenativeutils::CubeError; +use std::rc::Rc; + +use super::base_filter::FilterType; +use super::operators::comparison::{ComparisonKind, ComparisonOp}; +use super::operators::date_range::{DateRangeKind, DateRangeOp}; +use super::operators::date_single::{DateSingleKind, DateSingleOp}; +use super::operators::equality::EqualityOp; +use super::operators::in_list::InListOp; +use super::operators::like::LikeOp; +use super::operators::measure_filter::MeasureFilterOp; +use super::operators::nullability::NullabilityOp; +use super::operators::rolling_window::RegularRollingWindowOp; +use super::operators::to_date_rolling_window::ToDateRollingWindowOp; +use super::operators::{FilterOperationSql, FilterSqlContext}; +use super::FilterOperator; +use crate::planner::GranularityHelper; + +/// Resolves TimeDimension to its base dimension symbol; returns as-is for other kinds. +pub fn resolve_base_symbol(symbol: &Rc) -> Rc { + if let Ok(td) = symbol.as_time_dimension() { + td.base_symbol().clone() + } else { + symbol.clone() + } +} + +#[derive(Clone, Debug)] +pub enum FilterOp { + Comparison(ComparisonOp), + DateRange(DateRangeOp), + DateSingle(DateSingleOp), + Equality(EqualityOp), + InList(InListOp), + Like(LikeOp), + MeasureFilter(MeasureFilterOp), + Nullability(NullabilityOp), + RegularRollingWindow(RegularRollingWindowOp), + ToDateRollingWindow(ToDateRollingWindowOp), +} + +#[derive(Clone)] +pub struct TypedFilter { + query_tools: Rc, + member_evaluator: Rc, + filter_type: FilterType, + operator: FilterOperator, + values: Vec>, + use_raw_values: bool, + op: FilterOp, +} + +impl TypedFilter { + pub fn builder() -> TypedFilterBuilder { + TypedFilterBuilder::default() + } + + pub fn to_builder(&self) -> TypedFilterBuilder { + TypedFilter::builder() + .query_tools(self.query_tools.clone()) + .member_evaluator(self.member_evaluator.clone()) + .filter_type(self.filter_type.clone()) + .operator(self.operator.clone()) + .values(Some(self.values.clone())) + .use_raw_values(self.use_raw_values) + } + + pub fn query_tools(&self) -> &Rc { + &self.query_tools + } + + pub fn member_evaluator(&self) -> &Rc { + &self.member_evaluator + } + + pub fn filter_type(&self) -> &FilterType { + &self.filter_type + } + + pub fn operator(&self) -> &FilterOperator { + &self.operator + } + + pub fn values(&self) -> &Vec> { + &self.values + } + + pub fn use_raw_values(&self) -> bool { + self.use_raw_values + } + + pub fn to_sql( + &self, + context: Rc, + plan_templates: &PlanSqlTemplates, + ) -> Result { + if let FilterOp::MeasureFilter(op) = &self.op { + return op.to_sql( + &self.member_evaluator, + &self.query_tools, + &context, + plan_templates, + ); + } + + let resolved = resolve_base_symbol(&self.member_evaluator); + let member_sql = evaluate_with_context(&resolved, context.clone(), plan_templates)?; + + let filters_context = context.filters_context(); + let ctx = FilterSqlContext { + member_sql: &member_sql, + query_tools: &self.query_tools, + plan_templates, + use_db_time_zone: !filters_context.use_local_tz, + use_raw_values: self.use_raw_values, + }; + + self.dispatch_to_sql(&ctx) + } + + pub fn to_sql_for_filter_params( + &self, + column: &FilterParamsColumn, + plan_templates: &PlanSqlTemplates, + filters_context: &FiltersContext, + ) -> Result { + let use_db_time_zone = !filters_context.use_local_tz; + + match column { + FilterParamsColumn::String(column_sql) => { + let ctx = FilterSqlContext { + member_sql: column_sql, + query_tools: &self.query_tools, + plan_templates, + use_db_time_zone, + use_raw_values: self.use_raw_values, + }; + self.dispatch_to_sql(&ctx) + } + FilterParamsColumn::Callback(callback) => { + let args = match &self.op { + FilterOp::DateRange(_) | FilterOp::DateSingle(_) => { + let ctx = FilterSqlContext { + member_sql: "", + query_tools: &self.query_tools, + plan_templates, + use_db_time_zone, + use_raw_values: self.use_raw_values, + }; + let from = self + .values + .first() + .and_then(|v| v.as_ref()) + .map(|v| ctx.format_and_allocate_from_date(v)) + .transpose()?; + let to = self + .values + .get(1) + .and_then(|v| v.as_ref()) + .map(|v| ctx.format_and_allocate_to_date(v)) + .transpose()?; + [from, to].into_iter().flatten().collect() + } + _ => self + .values + .iter() + .filter_map(|v| v.as_ref().map(|v| self.query_tools.allocate_param(v))) + .collect::>(), + }; + callback.call(&args) + } + } + } + + fn dispatch_to_sql(&self, ctx: &FilterSqlContext) -> Result { + match &self.op { + FilterOp::Comparison(op) => op.to_sql(ctx), + FilterOp::DateRange(op) => op.to_sql(ctx), + FilterOp::DateSingle(op) => op.to_sql(ctx), + FilterOp::Equality(op) => op.to_sql(ctx), + FilterOp::InList(op) => op.to_sql(ctx), + FilterOp::Like(op) => op.to_sql(ctx), + FilterOp::MeasureFilter(_) => { + unreachable!("MeasureFilter is handled in TypedFilter::to_sql") + } + FilterOp::Nullability(op) => op.to_sql(ctx), + FilterOp::RegularRollingWindow(op) => op.to_sql(ctx), + FilterOp::ToDateRollingWindow(op) => op.to_sql(ctx), + } + } +} + +#[derive(Default)] +pub struct TypedFilterBuilder { + query_tools: Option>, + member_evaluator: Option>, + filter_type: Option, + operator: Option, + values: Option>>, + use_raw_values: bool, +} + +impl TypedFilterBuilder { + pub fn query_tools(mut self, v: Rc) -> Self { + self.query_tools = Some(v); + self + } + + pub fn member_evaluator(mut self, v: Rc) -> Self { + self.member_evaluator = Some(v); + self + } + + pub fn filter_type(mut self, v: FilterType) -> Self { + self.filter_type = Some(v); + self + } + + pub fn operator(mut self, v: FilterOperator) -> Self { + self.operator = Some(v); + self + } + + pub fn use_raw_values(mut self, v: bool) -> Self { + self.use_raw_values = v; + self + } + + pub fn values(mut self, v: Option>>) -> Self { + self.values = v; + self + } + + fn resolve_member_type(member_evaluator: &Rc) -> Option { + let symbol = resolve_base_symbol(member_evaluator); + match symbol.as_ref() { + MemberSymbol::Dimension(d) => Some(d.dimension_type().to_string()), + MemberSymbol::Measure(m) => Some(m.measure_type().to_string()), + _ => None, + } + } + + fn first_non_null_value(values: &[Option]) -> Result { + values + .iter() + .find_map(|v| v.as_ref().cloned()) + .ok_or_else(|| CubeError::user("Expected one parameter but nothing found".to_string())) + } + + pub fn build(self) -> Result { + let query_tools = self + .query_tools + .ok_or_else(|| CubeError::internal("query_tools is required".to_string()))?; + let member_evaluator = self + .member_evaluator + .ok_or_else(|| CubeError::internal("member_evaluator is required".to_string()))?; + let filter_type = self + .filter_type + .ok_or_else(|| CubeError::internal("filter_type is required".to_string()))?; + let operator = self + .operator + .ok_or_else(|| CubeError::internal("operator is required".to_string()))?; + let values = self.values.unwrap_or_default(); + let values_snapshot = values.clone(); + + let member_type = Self::resolve_member_type(&member_evaluator); + + let op = match operator { + FilterOperator::Equal | FilterOperator::NotEqual => { + let negated = matches!(operator, FilterOperator::NotEqual); + let has_null = values.iter().any(|v| v.is_none()); + if values.len() > 1 { + FilterOp::InList(InListOp::new(negated, values, member_type)) + } else if has_null { + // equals null → IS NULL, notEquals null → IS NOT NULL + FilterOp::Nullability(NullabilityOp::new(!negated)) + } else if let Some(Some(value)) = values.into_iter().next() { + FilterOp::Equality(EqualityOp::new(negated, value, member_type)) + } else { + return Err(CubeError::user( + "Expected at least one value for equals/notEquals filter".to_string(), + )); + } + } + FilterOperator::In => FilterOp::InList(InListOp::new(false, values, member_type)), + FilterOperator::NotIn => FilterOp::InList(InListOp::new(true, values, member_type)), + FilterOperator::Gt | FilterOperator::Gte | FilterOperator::Lt | FilterOperator::Lte => { + let kind = match operator { + FilterOperator::Gt => ComparisonKind::Gt, + FilterOperator::Gte => ComparisonKind::Gte, + FilterOperator::Lt => ComparisonKind::Lt, + FilterOperator::Lte => ComparisonKind::Lte, + _ => unreachable!(), + }; + let value = Self::first_non_null_value(&values)?; + FilterOp::Comparison(ComparisonOp::new(kind, value, member_type)) + } + FilterOperator::Set => FilterOp::Nullability(NullabilityOp::new(false)), + FilterOperator::NotSet => FilterOp::Nullability(NullabilityOp::new(true)), + FilterOperator::InDateRange | FilterOperator::NotInDateRange => { + let from = Self::first_non_null_value(&values)?; + let to = values + .get(1) + .and_then(|v| v.as_ref().cloned()) + .ok_or_else(|| { + CubeError::user("2 arguments expected for date range".to_string()) + })?; + let kind = if matches!(operator, FilterOperator::InDateRange) { + DateRangeKind::InRange + } else { + DateRangeKind::NotInRange + }; + FilterOp::DateRange(DateRangeOp::new(kind, from, to)) + } + FilterOperator::BeforeDate => { + let value = Self::first_non_null_value(&values)?; + FilterOp::DateSingle(DateSingleOp::new(DateSingleKind::Before, value)) + } + FilterOperator::BeforeOrOnDate => { + let value = Self::first_non_null_value(&values)?; + FilterOp::DateSingle(DateSingleOp::new(DateSingleKind::BeforeOrOn, value)) + } + FilterOperator::AfterDate => { + let value = Self::first_non_null_value(&values)?; + FilterOp::DateSingle(DateSingleOp::new(DateSingleKind::After, value)) + } + FilterOperator::AfterOrOnDate => { + let value = Self::first_non_null_value(&values)?; + FilterOp::DateSingle(DateSingleOp::new(DateSingleKind::AfterOrOn, value)) + } + FilterOperator::RegularRollingWindowDateRange => { + let trailing = values.get(2).and_then(|v| v.clone()); + let leading = values.get(3).and_then(|v| v.clone()); + FilterOp::RegularRollingWindow(RegularRollingWindowOp::new(trailing, leading)) + } + FilterOperator::ToDateRollingWindowDateRange => { + let granularity_name = values + .get(2) + .and_then(|v| v.as_ref()) + .ok_or_else(|| { + CubeError::user( + "Granularity required for to_date rolling window".to_string(), + ) + })? + .clone(); + + let resolved = resolve_base_symbol(&member_evaluator); + let evaluator_compiler_cell = query_tools.evaluator_compiler().clone(); + let mut evaluator_compiler = evaluator_compiler_cell.borrow_mut(); + + let granularity_obj = GranularityHelper::make_granularity_obj( + query_tools.cube_evaluator().clone(), + &mut evaluator_compiler, + &resolved.cube_name(), + &resolved.name(), + Some(granularity_name.clone()), + )? + .ok_or_else(|| { + CubeError::internal(format!( + "Rolling window granularity '{}' is not found in time dimension '{}'", + granularity_name, + resolved.name() + )) + })?; + + FilterOp::ToDateRollingWindow(ToDateRollingWindowOp::new(granularity_obj)) + } + FilterOperator::Contains + | FilterOperator::NotContains + | FilterOperator::StartsWith + | FilterOperator::NotStartsWith + | FilterOperator::EndsWith + | FilterOperator::NotEndsWith => { + let has_null = values.iter().any(|v| v.is_none()); + let non_null_values: Vec = + values.iter().filter_map(|v| v.clone()).collect(); + let (negated, start_wild, end_wild) = match operator { + FilterOperator::Contains => (false, true, true), + FilterOperator::NotContains => (true, true, true), + FilterOperator::StartsWith => (false, false, true), + FilterOperator::NotStartsWith => (true, false, true), + FilterOperator::EndsWith => (false, true, false), + FilterOperator::NotEndsWith => (true, true, false), + _ => unreachable!(), + }; + FilterOp::Like(LikeOp::new( + negated, + start_wild, + end_wild, + non_null_values, + has_null, + member_type, + )) + } + FilterOperator::MeasureFilter => FilterOp::MeasureFilter(MeasureFilterOp::new()), + }; + + Ok(TypedFilter { + query_tools, + member_evaluator, + filter_type, + operator, + values: values_snapshot, + use_raw_values: self.use_raw_values, + op, + }) + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs index 761498bae8fe3..4d289893cc3b5 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs @@ -311,12 +311,12 @@ impl MultiStageAppliedState { member_name: &String, trailing: &Option, leading: &Option, - ) { + ) -> Result<(), CubeError> { let trailing_unbounded = trailing.as_deref() == Some("unbounded"); let leading_unbounded = leading.as_deref() == Some("unbounded"); if !trailing_unbounded && !leading_unbounded { - return; + return Ok(()); } if trailing_unbounded && leading_unbounded { @@ -330,10 +330,9 @@ impl MultiStageAppliedState { }); } else if trailing_unbounded { // Remove lower bound: InDateRange(from, to) → BeforeOrOnDate(to) - self.time_dimensions_filters = self - .time_dimensions_filters - .iter() - .map(|item| match item { + let mut new_filters = Vec::new(); + for item in self.time_dimensions_filters.iter() { + match item { FilterItem::Item(itm) if &itm.member_name() == member_name && matches!(itm.filter_operator(), FilterOperator::InDateRange) => @@ -344,21 +343,21 @@ impl MultiStageAppliedState { } else { values.clone() }; - FilterItem::Item(itm.change_operator( + new_filters.push(FilterItem::Item(itm.change_operator( FilterOperator::BeforeOrOnDate, to_value, itm.use_raw_values(), - )) + )?)); } - other => other.clone(), - }) - .collect(); + other => new_filters.push(other.clone()), + } + } + self.time_dimensions_filters = new_filters; } else { // leading unbounded: remove upper bound: InDateRange(from, to) → AfterOrOnDate(from) - self.time_dimensions_filters = self - .time_dimensions_filters - .iter() - .map(|item| match item { + let mut new_filters = Vec::new(); + for item in self.time_dimensions_filters.iter() { + match item { FilterItem::Item(itm) if &itm.member_name() == member_name && matches!(itm.filter_operator(), FilterOperator::InDateRange) => @@ -369,16 +368,18 @@ impl MultiStageAppliedState { } else { values.clone() }; - FilterItem::Item(itm.change_operator( + new_filters.push(FilterItem::Item(itm.change_operator( FilterOperator::AfterOrOnDate, from_value, itm.use_raw_values(), - )) + )?)); } - other => other.clone(), - }) - .collect(); + other => new_filters.push(other.clone()), + } + } + self.time_dimensions_filters = new_filters; } + Ok(()) } pub fn replace_regular_date_range_filter( @@ -386,7 +387,7 @@ impl MultiStageAppliedState { member_name: &String, left_interval: Option, right_interval: Option, - ) { + ) -> Result<(), CubeError> { let operator = FilterOperator::RegularRollingWindowDateRange; let values = vec![left_interval.clone(), right_interval.clone()]; self.time_dimensions_filters = self.change_date_range_filter_impl( @@ -396,14 +397,15 @@ impl MultiStageAppliedState { None, &values, &None, - ); + )?; + Ok(()) } pub fn replace_to_date_date_range_filter( &mut self, member_name: &String, granularity: &String, - ) { + ) -> Result<(), CubeError> { let operator = FilterOperator::ToDateRollingWindowDateRange; let values = vec![Some(granularity.clone())]; self.time_dimensions_filters = self.change_date_range_filter_impl( @@ -413,7 +415,8 @@ impl MultiStageAppliedState { None, &values, &None, - ); + )?; + Ok(()) } pub fn replace_range_in_date_filter( @@ -421,7 +424,7 @@ impl MultiStageAppliedState { member_name: &String, new_from: String, new_to: String, - ) { + ) -> Result<(), CubeError> { let operator = FilterOperator::InDateRange; let replacement_values = vec![Some(new_from), Some(new_to)]; self.time_dimensions_filters = self.change_date_range_filter_impl( @@ -431,7 +434,8 @@ impl MultiStageAppliedState { None, &vec![], &Some(replacement_values), - ); + )?; + Ok(()) } pub fn replace_range_to_subquery_in_date_filter( @@ -439,7 +443,7 @@ impl MultiStageAppliedState { member_name: &String, new_from: String, new_to: String, - ) { + ) -> Result<(), CubeError> { let operator = FilterOperator::InDateRange; let replacement_values = vec![Some(new_from), Some(new_to)]; self.time_dimensions_filters = self.change_date_range_filter_impl( @@ -449,7 +453,8 @@ impl MultiStageAppliedState { Some(true), &vec![], &Some(replacement_values), - ); + )?; + Ok(()) } fn change_date_range_filter_impl( @@ -460,7 +465,7 @@ impl MultiStageAppliedState { use_raw_values: Option, additional_values: &Vec>, replacement_values: &Option>>, - ) -> Vec { + ) -> Result, CubeError> { let mut result = Vec::new(); for item in filters.iter() { match item { @@ -474,7 +479,7 @@ impl MultiStageAppliedState { use_raw_values, additional_values, replacement_values, - ), + )?, ))); result.push(new_group); } @@ -489,7 +494,7 @@ impl MultiStageAppliedState { }; values.extend(additional_values.iter().cloned()); let use_raw_values = use_raw_values.unwrap_or(itm.use_raw_values()); - itm.change_operator(operator.clone(), values, use_raw_values) + itm.change_operator(operator.clone(), values, use_raw_values)? } else { itm.clone() }; @@ -498,7 +503,7 @@ impl MultiStageAppliedState { FilterItem::Segment(segment) => result.push(FilterItem::Segment(segment.clone())), } } - result + Ok(result) } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs index 92e20e428fece..3fc37dfc48e58 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs @@ -475,7 +475,7 @@ impl MultiStageQueryPlanner { if time_dimensions.is_empty() { let base_state = - self.replace_date_range_for_rolling_window(&rolling_window, state.clone()); + self.replace_date_range_for_rolling_window(&rolling_window, state.clone())?; let rolling_base = self.add_rolling_window_base( member.clone(), base_state, @@ -707,7 +707,7 @@ impl MultiStageQueryPlanner { &self, rolling_window: &RollingWindow, state: Rc, - ) -> Rc { + ) -> Result, CubeError> { let mut new_state = state.clone_state(); for filter_item in state.time_dimensions_filters() { if let FilterItem::Item(filter) = filter_item { @@ -716,11 +716,11 @@ impl MultiStageQueryPlanner { &filter.member_name(), &rolling_window.trailing, &rolling_window.leading, - ); + )?; } } } - Rc::new(new_state) + Ok(Rc::new(new_state)) } fn make_rolling_base_state( @@ -763,13 +763,13 @@ impl MultiStageQueryPlanner { new_state.set_dimensions(dimensions); if let Some(granularity) = self.get_to_date_rolling_granularity(rolling_window)? { - new_state.replace_to_date_date_range_filter(&time_dimension_base_name, &granularity); + new_state.replace_to_date_date_range_filter(&time_dimension_base_name, &granularity)?; } else { new_state.replace_regular_date_range_filter( &time_dimension_base_name, rolling_window.trailing.clone(), rolling_window.leading.clone(), - ); + )?; } Ok((Rc::new(new_state), new_time_dimension)) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/common/static_filter.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/common/static_filter.rs index 58291ef702da0..f3f9c909c7366 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/common/static_filter.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/common/static_filter.rs @@ -81,7 +81,7 @@ pub fn apply_static_filter_to_filter_item( *item = item.with_member_evaluator(apply_static_filter_to_symbol( &item.raw_member_evaluator(), filters, - )?); + )?)?; } FilterItem::Segment(item) => { *item = item.with_member_evaluator(apply_static_filter_to_symbol( diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_driver_tools.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_driver_tools.rs index a5eea38949fc9..4c83037840c28 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_driver_tools.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_driver_tools.rs @@ -17,6 +17,7 @@ pub struct MockDriverTools { timezone: String, timestamp_precision: u32, sql_templates: Rc, + visible_in_db_time_zone: bool, } impl MockDriverTools { @@ -25,6 +26,7 @@ impl MockDriverTools { timezone: "UTC".to_string(), timestamp_precision: 3, sql_templates: Rc::new(MockSqlTemplatesRender::default_templates()), + visible_in_db_time_zone: false, } } @@ -34,6 +36,7 @@ impl MockDriverTools { timezone, timestamp_precision: 3, sql_templates: Rc::new(MockSqlTemplatesRender::default_templates()), + visible_in_db_time_zone: false, } } @@ -43,8 +46,15 @@ impl MockDriverTools { timezone: "UTC".to_string(), timestamp_precision: 3, sql_templates: Rc::new(sql_templates), + visible_in_db_time_zone: false, } } + + #[allow(dead_code)] + pub fn with_visible_in_db_time_zone(mut self) -> Self { + self.visible_in_db_time_zone = true; + self + } } impl Default for MockDriverTools { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs index f2eebd5d80b41..415b6a2decd79 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs @@ -213,6 +213,36 @@ impl MockSchema { Ok(result) } + pub fn create_base_tools_with_driver( + &self, + driver_tools: MockDriverTools, + ) -> Result { + let join_graph = Rc::new(self.create_join_graph()?); + let driver_tools = Rc::new(driver_tools); + + let mut cube_members = HashMap::new(); + for (cube_name, cube) in &self.cubes { + let mut members = Vec::new(); + for dim_name in cube.dimensions.keys() { + members.push(format!("{}.{}", cube_name, dim_name)); + } + for measure_name in cube.measures.keys() { + members.push(format!("{}.{}", cube_name, measure_name)); + } + for segment_name in cube.segments.keys() { + members.push(format!("{}.{}", cube_name, segment_name)); + } + cube_members.insert(cube_name.clone(), members); + } + + let result = MockBaseTools::builder() + .join_graph(join_graph) + .driver_tools(driver_tools) + .cube_members(cube_members) + .build(); + Ok(result) + } + #[allow(dead_code)] pub fn create_evaluator_with_primary_keys( self, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_sql_templates_render.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_sql_templates_render.rs index 2ffbf3af8432d..5608a567dfb30 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_sql_templates_render.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_sql_templates_render.rs @@ -372,11 +372,11 @@ impl MockSqlTemplatesRender { ); templates.insert( "tesseract/bool_param_cast".to_string(), - "{{ expr }}".to_string(), + "{{ expr }}::boolean".to_string(), ); templates.insert( "tesseract/number_param_cast".to_string(), - "{{ expr }}".to_string(), + "{{ expr }}::numeric".to_string(), ); // Filters - based on BaseQuery.js:4398-4414 diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/rolling_window.yaml b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/rolling_window.yaml index 03d6d227d53ee..904847f49b6c0 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/rolling_window.yaml +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/rolling_window.yaml @@ -22,3 +22,20 @@ cubes: rolling_window: trailing: unbounded leading: unbounded + - name: val_bounded + type: sum + sql: val + rolling_window: + trailing: 3 day + leading: 1 day + - name: val_trailing_bounded + type: sum + sql: val + rolling_window: + trailing: 7 day + - name: val_to_date + type: sum + sql: val + rolling_window: + type: to_date + granularity: month diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/visitors.yaml b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/visitors.yaml index 523983e23a8c8..cd9b29bcb6664 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/visitors.yaml +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/visitors.yaml @@ -61,6 +61,9 @@ cubes: type: geo latitude: latitude longitude: longitude + - name: is_active + type: boolean + sql: is_active - name: questionMark type: string sql: "replace('some string question string ? ?? ???', 'string', 'with some ? ?? ???')" diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs index 8a8b4910a903e..69bdf88529067 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs @@ -3,16 +3,17 @@ use crate::cube_bridge::join_hints::JoinHintItem; use crate::logical_plan::PreAggregation; #[cfg(feature = "integration-postgres")] use crate::logical_plan::{PreAggregationSource, PreAggregationTable}; +use crate::plan::Filter; use crate::planner::filter::base_segment::BaseSegment; use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::sql_nodes::SqlNodesFactory; use crate::planner::sql_evaluator::{MemberSymbol, SqlEvaluatorVisitor, TimeDimensionSymbol}; use crate::planner::sql_templates::PlanSqlTemplates; use crate::planner::top_level_planner::TopLevelPlanner; -use crate::planner::{GranularityHelper, QueryProperties}; +use crate::planner::{GranularityHelper, QueryProperties, VisitorContext}; use crate::test_fixtures::cube_bridge::yaml::YamlBaseQueryOptions; use crate::test_fixtures::cube_bridge::{ - members_from_strings, MockBaseQueryOptions, MockSchema, MockSecurityContext, + members_from_strings, MockBaseQueryOptions, MockBaseTools, MockSchema, MockSecurityContext, }; use chrono_tz::Tz; use cubenativeutils::CubeError; @@ -30,6 +31,34 @@ impl TestContext { Self::new_with_options(schema, Tz::UTC, None, None, false) } + #[allow(dead_code)] + pub fn new_with_base_tools( + schema: MockSchema, + base_tools: MockBaseTools, + ) -> Result { + let join_graph = Rc::new(schema.create_join_graph()?); + let evaluator = schema.clone().create_evaluator(); + let security_context: Rc = + Rc::new(MockSecurityContext); + + let query_tools = QueryTools::try_new( + evaluator, + security_context.clone(), + Rc::new(base_tools), + join_graph, + Some(Tz::UTC.to_string()), + false, + None, + None, + )?; + + Ok(Self { + schema, + query_tools, + security_context, + }) + } + #[allow(dead_code)] pub fn new_with_timezone(schema: MockSchema, timezone: Tz) -> Result { Self::new_with_options(schema, timezone, None, None, false) @@ -541,6 +570,53 @@ impl TestContext { } result } + + pub fn build_filter_sql(&self, yaml: &str) -> Result<(String, Vec), CubeError> { + let props = self.create_query_properties(yaml)?; + + let filter = Filter { + items: props + .dimensions_filters() + .iter() + .chain(props.time_dimensions_filters().iter()) + .chain(props.measures_filters().iter()) + .cloned() + .collect(), + }; + + let nodes_factory = SqlNodesFactory::default(); + let context = Rc::new(VisitorContext::new( + self.query_tools.clone(), + &nodes_factory, + None, + )); + let base_tools = self.query_tools.base_tools(); + let driver_tools = base_tools.driver_tools(false)?; + let templates = PlanSqlTemplates::try_new(driver_tools, false)?; + + let sql = filter.to_sql(&templates, context)?; + let params = self.query_tools.get_allocated_params(); + Ok((sql, params)) + } + + pub fn build_base_filter_sql( + &self, + base_filter: &Rc, + ) -> Result<(String, Vec), CubeError> { + let nodes_factory = SqlNodesFactory::default(); + let context = Rc::new(VisitorContext::new( + self.query_tools.clone(), + &nodes_factory, + None, + )); + let base_tools = self.query_tools.base_tools(); + let driver_tools = base_tools.driver_tools(false)?; + let templates = PlanSqlTemplates::try_new(driver_tools, false)?; + + let sql = base_filter.to_sql(context, &templates)?; + let params = self.query_tools.get_allocated_params(); + Ok((sql, params)) + } } #[cfg(test)] diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/mod.rs new file mode 100644 index 0000000000000..4bbf17a0a1268 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/mod.rs @@ -0,0 +1,26 @@ +mod partition_range; +mod to_sql; +mod to_sql_timezone; +mod use_raw_values; + +use crate::test_fixtures::cube_bridge::MockSchema; +use crate::test_fixtures::test_utils::TestContext; + +pub fn build_filter(schema_file: &str, filter_yaml: &str) -> (String, Vec) { + let schema = MockSchema::from_yaml_file(schema_file); + let ctx = TestContext::new(schema).unwrap(); + + let query = format!("measures:\n - visitors.count\n{}", filter_yaml); + ctx.build_filter_sql(&query) + .expect("Should generate filter SQL") +} + +pub fn assert_filter(result: &(String, Vec), expected_sql: &str, expected_params: &[&str]) { + assert_eq!(result.0, expected_sql, "SQL mismatch"); + let params: Vec<&str> = result.1.iter().map(|s| s.as_str()).collect(); + assert_eq!( + params, expected_params, + "Params mismatch for SQL: {}", + result.0 + ); +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/partition_range.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/partition_range.rs new file mode 100644 index 0000000000000..25892b3e8f37c --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/partition_range.rs @@ -0,0 +1,161 @@ +use super::assert_filter; +use crate::test_fixtures::cube_bridge::{MockDriverTools, MockSchema}; +use crate::test_fixtures::test_utils::TestContext; +use indoc::indoc; + +fn build(filter_yaml: &str) -> (String, Vec) { + super::build_filter("common/visitors.yaml", filter_yaml) +} + +fn build_with_visible_tz(filter_yaml: &str) -> (String, Vec) { + let schema = MockSchema::from_yaml_file("common/visitors.yaml"); + let driver = MockDriverTools::with_timezone("America/Los_Angeles".to_string()) + .with_visible_in_db_time_zone(); + let base_tools = schema.create_base_tools_with_driver(driver).unwrap(); + let ctx = TestContext::new_with_base_tools(schema, base_tools).unwrap(); + + let query = format!("measures:\n - visitors.count\n{}", filter_yaml); + ctx.build_filter_sql(&query) + .expect("Should generate filter SQL") +} + +#[test] +fn test_in_date_range_from_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "__FROM_PARTITION_RANGE" + - "2024-12-31" + "#}); + // __FROM_PARTITION_RANGE skips formatting and tz conversion but still gets allocated + cast + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE", "2024-12-31T23:59:59.999"], + ); +} + +#[test] +fn test_in_date_range_to_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "2024-01-01" + - "__TO_PARTITION_RANGE" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["2024-01-01T00:00:00.000", "__TO_PARTITION_RANGE"], + ); +} + +#[test] +fn test_in_date_range_both_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "__FROM_PARTITION_RANGE" + - "__TO_PARTITION_RANGE" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE", "__TO_PARTITION_RANGE"], + ); +} + +#[test] +fn test_not_in_date_range_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: notInDateRange + values: + - "__FROM_PARTITION_RANGE" + - "__TO_PARTITION_RANGE" + "#}); + assert_filter( + &result, + r#"("visitors".created_at < $_0_$::timestamptz OR "visitors".created_at > $_1_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE", "__TO_PARTITION_RANGE"], + ); +} + +#[test] +fn test_before_date_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: beforeDate + values: + - "__TO_PARTITION_RANGE" + "#}); + assert_filter( + &result, + r#"("visitors".created_at < $_0_$::timestamptz)"#, + &["__TO_PARTITION_RANGE"], + ); +} + +#[test] +fn test_after_or_on_date_partition_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: afterOrOnDate + values: + - "__FROM_PARTITION_RANGE" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE"], + ); +} + +// ── partition range + db timezone ────────────────────────────────────────── +// Partition range values must skip tz conversion even when db timezone is enabled + +#[test] +fn test_partition_range_skips_db_timezone() { + let result = build_with_visible_tz(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "__FROM_PARTITION_RANGE" + - "__TO_PARTITION_RANGE" + "#}); + // Regular dates get tz conversion, but partition range values must NOT + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE", "__TO_PARTITION_RANGE"], + ); +} + +#[test] +fn test_partition_range_mixed_with_regular_date_and_tz() { + let result = build_with_visible_tz(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "__FROM_PARTITION_RANGE" + - "2024-12-31" + "#}); + // FROM: partition range — no tz conversion, no formatting + // TO: regular date — gets formatted and tz-converted + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["__FROM_PARTITION_RANGE", "2025-01-01T07:59:59.999"], + ); +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql.rs new file mode 100644 index 0000000000000..2960a02e66413 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql.rs @@ -0,0 +1,710 @@ +use super::{assert_filter, build_filter}; +use indoc::indoc; + +fn build(filter_yaml: &str) -> (String, Vec) { + build_filter("common/visitors.yaml", filter_yaml) +} + +// ── equals ────────────────────────────────────────────────────────────────── + +#[test] +fn test_equals_string() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: equals + values: + - google + "}); + assert_filter(&result, r#"("visitors".source = $_0_$)"#, &["google"]); +} + +#[test] +fn test_equals_number() { + let result = build(indoc! {" + filters: + - dimension: visitors.id + operator: equals + values: + - \"42\" + "}); + assert_filter(&result, r#"("visitors".id = $_0_$::numeric)"#, &["42"]); +} + +#[test] +fn test_equals_boolean() { + let result = build(indoc! {" + filters: + - dimension: visitors.is_active + operator: equals + values: + - \"true\" + "}); + assert_filter( + &result, + r#"("visitors".is_active = $_0_$::boolean)"#, + &["true"], + ); +} + +#[test] +fn test_equals_multiple_values() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: equals + values: + - google + - facebook + "}); + assert_filter( + &result, + r#"("visitors".source IN ($_0_$, $_1_$))"#, + &["google", "facebook"], + ); +} + +#[test] +fn test_equals_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: equals + values: + - + "}); + assert_filter(&result, r#"("visitors".source IS NULL)"#, &[]); +} + +#[test] +fn test_equals_values_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: equals + values: + - google + - + "}); + assert_filter( + &result, + r#"("visitors".source IN ($_0_$) OR "visitors".source IS NULL)"#, + &["google"], + ); +} + +#[test] +fn test_not_equals_values_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notEquals + values: + - google + - + "}); + assert_filter( + &result, + r#"("visitors".source NOT IN ($_0_$))"#, + &["google"], + ); +} + +// ── notEquals ─────────────────────────────────────────────────────────────── + +#[test] +fn test_not_equals_string() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notEquals + values: + - google + "}); + assert_filter( + &result, + r#"("visitors".source <> $_0_$ OR "visitors".source IS NULL)"#, + &["google"], + ); +} + +#[test] +fn test_not_equals_multiple_values() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notEquals + values: + - google + - facebook + "}); + assert_filter( + &result, + r#"("visitors".source NOT IN ($_0_$, $_1_$) OR "visitors".source IS NULL)"#, + &["google", "facebook"], + ); +} + +#[test] +fn test_not_equals_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notEquals + values: + - + "}); + assert_filter(&result, r#"("visitors".source IS NOT NULL)"#, &[]); +} + +// ── in / notIn ────────────────────────────────────────────────────────────── + +#[test] +fn test_in_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: in + values: + - google + - facebook + - twitter + "}); + assert_filter( + &result, + r#"("visitors".source IN ($_0_$, $_1_$, $_2_$))"#, + &["google", "facebook", "twitter"], + ); +} + +#[test] +fn test_in_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: in + values: + - google + - + "}); + assert_filter( + &result, + r#"("visitors".source IN ($_0_$) OR "visitors".source IS NULL)"#, + &["google"], + ); +} + +#[test] +fn test_not_in_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notIn + values: + - google + - facebook + "}); + assert_filter( + &result, + r#"("visitors".source NOT IN ($_0_$, $_1_$) OR "visitors".source IS NULL)"#, + &["google", "facebook"], + ); +} + +#[test] +fn test_not_in_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notIn + values: + - google + - + "}); + assert_filter( + &result, + r#"("visitors".source NOT IN ($_0_$))"#, + &["google"], + ); +} + +// ── set / notSet ──────────────────────────────────────────────────────────── + +#[test] +fn test_set_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: set + "}); + assert_filter(&result, r#"("visitors".source IS NOT NULL)"#, &[]); +} + +#[test] +fn test_not_set_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notSet + "}); + assert_filter(&result, r#"("visitors".source IS NULL)"#, &[]); +} + +// ── comparison operators ──────────────────────────────────────────────────── + +#[test] +fn test_gt_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.id + operator: gt + values: + - \"100\" + "}); + assert_filter(&result, r#"("visitors".id > $_0_$::numeric)"#, &["100"]); +} + +#[test] +fn test_gte_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.id + operator: gte + values: + - \"100\" + "}); + assert_filter(&result, r#"("visitors".id >= $_0_$::numeric)"#, &["100"]); +} + +#[test] +fn test_lt_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.id + operator: lt + values: + - \"100\" + "}); + assert_filter(&result, r#"("visitors".id < $_0_$::numeric)"#, &["100"]); +} + +#[test] +fn test_lte_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.id + operator: lte + values: + - \"100\" + "}); + assert_filter(&result, r#"("visitors".id <= $_0_$::numeric)"#, &["100"]); +} + +#[test] +fn test_gt_string_no_cast() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: gt + values: + - abc + "}); + assert_filter(&result, r#"("visitors".source > $_0_$)"#, &["abc"]); +} + +#[test] +fn test_lte_string_no_cast() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: lte + values: + - zzz + "}); + assert_filter(&result, r#"("visitors".source <= $_0_$)"#, &["zzz"]); +} + +#[test] +fn test_contains_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: contains + values: + - goo + "}); + assert_filter( + &result, + r#"(("visitors".source ILIKE '%' || $_0_$|| '%'))"#, + &["goo"], + ); +} + +#[test] +fn test_not_contains_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notContains + values: + - goo + "}); + assert_filter( + &result, + r#"(("visitors".source NOT ILIKE '%' || $_0_$|| '%') OR "visitors".source IS NULL)"#, + &["goo"], + ); +} + +#[test] +fn test_starts_with_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: startsWith + values: + - goo + "}); + assert_filter( + &result, + r#"(("visitors".source ILIKE $_0_$|| '%'))"#, + &["goo"], + ); +} + +#[test] +fn test_not_starts_with_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notStartsWith + values: + - goo + "}); + assert_filter( + &result, + r#"(("visitors".source NOT ILIKE $_0_$|| '%') OR "visitors".source IS NULL)"#, + &["goo"], + ); +} + +#[test] +fn test_ends_with_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: endsWith + values: + - gle + "}); + assert_filter( + &result, + r#"(("visitors".source ILIKE '%' || $_0_$))"#, + &["gle"], + ); +} + +#[test] +fn test_not_ends_with_filter() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notEndsWith + values: + - gle + "}); + assert_filter( + &result, + r#"(("visitors".source NOT ILIKE '%' || $_0_$) OR "visitors".source IS NULL)"#, + &["gle"], + ); +} + +// ── contains with multiple values ─────────────────────────────────────────── + +#[test] +fn test_contains_multiple_values() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: contains + values: + - goo + - face + "}); + assert_filter( + &result, + r#"(("visitors".source ILIKE '%' || $_0_$|| '%' OR "visitors".source ILIKE '%' || $_1_$|| '%'))"#, + &["goo", "face"], + ); +} + +#[test] +fn test_not_contains_multiple_values() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notContains + values: + - goo + - face + "}); + assert_filter( + &result, + r#"(("visitors".source NOT ILIKE '%' || $_0_$|| '%' AND "visitors".source NOT ILIKE '%' || $_1_$|| '%') OR "visitors".source IS NULL)"#, + &["goo", "face"], + ); +} + +// ── like with null ────────────────────────────────────────────────────────── + +#[test] +fn test_contains_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: contains + values: + - goo + - + "}); + assert_filter( + &result, + r#"(("visitors".source ILIKE '%' || $_0_$|| '%') OR "visitors".source IS NULL)"#, + &["goo"], + ); +} + +#[test] +fn test_not_contains_with_null() { + let result = build(indoc! {" + filters: + - dimension: visitors.source + operator: notContains + values: + - goo + - + "}); + assert_filter( + &result, + r#"(("visitors".source NOT ILIKE '%' || $_0_$|| '%'))"#, + &["goo"], + ); +} + +// ── filter groups (OR / AND) ──────────────────────────────────────────────── + +#[test] +fn test_or_filter_group() { + let result = build(indoc! {" + filters: + - or: + - dimension: visitors.source + operator: equals + values: + - google + - dimension: visitors.source + operator: equals + values: + - facebook + "}); + assert_filter( + &result, + r#"(("visitors".source = $_0_$) OR ("visitors".source = $_1_$))"#, + &["google", "facebook"], + ); +} + +#[test] +fn test_and_filter_group() { + let result = build(indoc! {" + filters: + - and: + - dimension: visitors.source + operator: equals + values: + - google + - dimension: visitors.id + operator: gt + values: + - \"100\" + "}); + assert_filter( + &result, + r#"(("visitors".source = $_0_$) AND ("visitors".id > $_1_$::numeric))"#, + &["google", "100"], + ); +} + +#[test] +fn test_nested_and_or_groups() { + // AND(OR(eq, eq), OR(AND(gt, contains), lt), set) + let result = build(indoc! {" + filters: + - and: + - or: + - dimension: visitors.source + operator: equals + values: + - google + - dimension: visitors.source + operator: equals + values: + - facebook + - or: + - and: + - dimension: visitors.id + operator: gt + values: + - \"100\" + - dimension: visitors.source + operator: contains + values: + - goo + - dimension: visitors.id + operator: lt + values: + - \"10\" + - dimension: visitors.source + operator: set + "}); + assert_filter( + &result, + r#"((("visitors".source = $_0_$) OR ("visitors".source = $_1_$)) AND ((("visitors".id > $_2_$::numeric) AND (("visitors".source ILIKE '%' || $_3_$|| '%'))) OR ("visitors".id < $_4_$::numeric)) AND ("visitors".source IS NOT NULL))"#, + &["google", "facebook", "100", "goo", "10"], + ); +} + +// ── date operators ────────────────────────────────────────────────────────── + +#[test] +fn test_in_date_range_date_only() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "2024-01-01" + - "2024-12-31" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["2024-01-01T00:00:00.000", "2024-12-31T23:59:59.999"], + ); +} + +#[test] +fn test_in_date_range_full_timestamp() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "2024-01-01T10:00:00.000" + - "2024-06-15T18:30:00.000" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["2024-01-01T10:00:00.000", "2024-06-15T18:30:00.000"], + ); +} + +#[test] +fn test_not_in_date_range() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: notInDateRange + values: + - "2024-01-01" + - "2024-12-31" + "#}); + assert_filter( + &result, + r#"("visitors".created_at < $_0_$::timestamptz OR "visitors".created_at > $_1_$::timestamptz)"#, + &["2024-01-01T00:00:00.000", "2024-12-31T23:59:59.999"], + ); +} + +#[test] +fn test_before_date() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: beforeDate + values: + - "2024-06-01" + "#}); + assert_filter( + &result, + r#"("visitors".created_at < $_0_$::timestamptz)"#, + &["2024-06-01T00:00:00.000"], + ); +} + +#[test] +fn test_before_or_on_date() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: beforeOrOnDate + values: + - "2024-06-01" + "#}); + assert_filter( + &result, + r#"("visitors".created_at <= $_0_$::timestamptz)"#, + &["2024-06-01T23:59:59.999"], + ); +} + +#[test] +fn test_after_date() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: afterDate + values: + - "2024-06-01" + "#}); + assert_filter( + &result, + r#"("visitors".created_at > $_0_$::timestamptz)"#, + &["2024-06-01T23:59:59.999"], + ); +} + +#[test] +fn test_after_or_on_date() { + let result = build(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: afterOrOnDate + values: + - "2024-06-01" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz)"#, + &["2024-06-01T00:00:00.000"], + ); +} + +#[test] +fn test_time_dimension_date_range() { + let result = build(indoc! {r#" + time_dimensions: + - dimension: visitors.created_at + granularity: day + dateRange: + - "2024-01-01" + - "2024-12-31" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["2024-01-01T00:00:00.000", "2024-12-31T23:59:59.999"], + ); +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql_timezone.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql_timezone.rs new file mode 100644 index 0000000000000..b5db2d92e07e4 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/to_sql_timezone.rs @@ -0,0 +1,61 @@ +use super::assert_filter; +use crate::test_fixtures::cube_bridge::{MockDriverTools, MockSchema}; +use crate::test_fixtures::test_utils::TestContext; +use indoc::indoc; + +fn build_with_visible_tz(filter_yaml: &str) -> (String, Vec) { + let schema = MockSchema::from_yaml_file("common/visitors.yaml"); + let driver = MockDriverTools::with_timezone("America/Los_Angeles".to_string()) + .with_visible_in_db_time_zone(); + let base_tools = schema.create_base_tools_with_driver(driver).unwrap(); + let ctx = TestContext::new_with_base_tools(schema, base_tools).unwrap(); + + let query = format!("measures:\n - visitors.count\n{}", filter_yaml); + ctx.build_filter_sql(&query) + .expect("Should generate filter SQL") +} + +#[test] +fn test_in_date_range_applies_in_db_time_zone() { + let result = build_with_visible_tz(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: inDateRange + values: + - "2024-01-01" + - "2024-12-31" + "#}); + assert_filter( + &result, + r#"("visitors".created_at >= $_0_$::timestamptz AND "visitors".created_at <= $_1_$::timestamptz)"#, + &["2024-01-01T08:00:00.000", "2025-01-01T07:59:59.999"], + ); +} + +#[test] +fn test_before_date_applies_in_db_time_zone() { + let result = build_with_visible_tz(indoc! {r#" + filters: + - dimension: visitors.created_at + operator: beforeDate + values: + - "2024-06-01" + "#}); + assert_filter( + &result, + r#"("visitors".created_at < $_0_$::timestamptz)"#, + &["2024-06-01T07:00:00.000"], + ); +} + +#[test] +fn test_non_date_filter_unaffected_by_in_db_time_zone() { + let result = build_with_visible_tz(indoc! {" + filters: + - dimension: visitors.source + operator: equals + values: + - google + "}); + assert_filter(&result, r#"("visitors".source = $_0_$)"#, &["google"]); +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/use_raw_values.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/use_raw_values.rs new file mode 100644 index 0000000000000..ac4ebec1df8bd --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/filter/use_raw_values.rs @@ -0,0 +1,152 @@ +use crate::planner::filter::base_filter::{BaseFilter, FilterType}; +use crate::planner::filter::filter_operator::FilterOperator; +use crate::test_fixtures::cube_bridge::MockSchema; +use crate::test_fixtures::test_utils::TestContext; + +fn create_ctx() -> TestContext { + let schema = MockSchema::from_yaml_file("common/visitors.yaml"); + TestContext::new(schema).unwrap() +} + +fn assert_raw(result: &(String, Vec), expected_sql: &str) { + assert_eq!(result.0, expected_sql, "SQL mismatch"); + assert!( + result.1.is_empty(), + "Expected no allocated params for raw values, got: {:?}", + result.1 + ); +} + +#[test] +fn test_in_date_range_use_raw_values() { + let ctx = create_ctx(); + let symbol = ctx.create_symbol("visitors.created_at").unwrap(); + + let filter = BaseFilter::try_new( + ctx.query_tools().clone(), + symbol, + FilterType::Dimension, + FilterOperator::InDateRange, + Some(vec![ + Some("2024-01-01".to_string()), + Some("2024-12-31".to_string()), + ]), + ) + .unwrap(); + + let raw_filter = filter + .change_operator( + FilterOperator::InDateRange, + vec![ + Some("(SELECT min(df) FROM cte)".to_string()), + Some("(SELECT max(dt) FROM cte)".to_string()), + ], + true, + ) + .unwrap(); + + let result = ctx.build_base_filter_sql(&raw_filter).unwrap(); + assert_raw( + &result, + r#""visitors".created_at >= (SELECT min(df) FROM cte) AND "visitors".created_at <= (SELECT max(dt) FROM cte)"#, + ); +} + +#[test] +fn test_not_in_date_range_use_raw_values() { + let ctx = create_ctx(); + let symbol = ctx.create_symbol("visitors.created_at").unwrap(); + + let filter = BaseFilter::try_new( + ctx.query_tools().clone(), + symbol, + FilterType::Dimension, + FilterOperator::NotInDateRange, + Some(vec![ + Some("2024-01-01".to_string()), + Some("2024-12-31".to_string()), + ]), + ) + .unwrap(); + + let raw_filter = filter + .change_operator( + FilterOperator::NotInDateRange, + vec![ + Some("(SELECT min(df) FROM cte)".to_string()), + Some("(SELECT max(dt) FROM cte)".to_string()), + ], + true, + ) + .unwrap(); + + let result = ctx.build_base_filter_sql(&raw_filter).unwrap(); + assert_raw( + &result, + r#""visitors".created_at < (SELECT min(df) FROM cte) OR "visitors".created_at > (SELECT max(dt) FROM cte)"#, + ); +} + +#[test] +fn test_before_or_on_date_use_raw_values() { + let ctx = create_ctx(); + let symbol = ctx.create_symbol("visitors.created_at").unwrap(); + + let filter = BaseFilter::try_new( + ctx.query_tools().clone(), + symbol, + FilterType::Dimension, + FilterOperator::InDateRange, + Some(vec![ + Some("2024-01-01".to_string()), + Some("2024-12-31".to_string()), + ]), + ) + .unwrap(); + + let raw_filter = filter + .change_operator( + FilterOperator::BeforeOrOnDate, + vec![Some("(SELECT max(dt) FROM cte)".to_string())], + true, + ) + .unwrap(); + + let result = ctx.build_base_filter_sql(&raw_filter).unwrap(); + assert_raw( + &result, + r#""visitors".created_at <= (SELECT max(dt) FROM cte)"#, + ); +} + +#[test] +fn test_after_or_on_date_use_raw_values() { + let ctx = create_ctx(); + let symbol = ctx.create_symbol("visitors.created_at").unwrap(); + + let filter = BaseFilter::try_new( + ctx.query_tools().clone(), + symbol, + FilterType::Dimension, + FilterOperator::InDateRange, + Some(vec![ + Some("2024-01-01".to_string()), + Some("2024-12-31".to_string()), + ]), + ) + .unwrap(); + + let raw_filter = filter + .change_operator( + FilterOperator::AfterOrOnDate, + vec![Some("(SELECT min(df) FROM cte)".to_string())], + true, + ) + .unwrap(); + + let result = ctx.build_base_filter_sql(&raw_filter).unwrap(); + assert_raw( + &result, + r#""visitors".created_at >= (SELECT min(df) FROM cte)"#, + ); +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/mod.rs index 191a21e278089..5f237d65ec8a5 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/tests/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/mod.rs @@ -4,6 +4,7 @@ mod cube_evaluator; mod cube_names_collector; mod date_filters; mod dimension_symbol; +mod filter; mod join_hints_collector; mod measure_symbol; mod member_expressions_on_views; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/rolling_window_sql_generation.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/rolling_window_sql_generation.rs index 54effdc760f3d..54edccec72fce 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/tests/rolling_window_sql_generation.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/rolling_window_sql_generation.rs @@ -26,7 +26,11 @@ async fn test_rolling_window_trailing_unbounded_no_granularity() { .expect("Should generate SQL for trailing unbounded"); assert!( - !sql.contains(">= $_0_$"), + sql.contains(r#""test_cube".created_at <= $_0_$::timestamptz"#), + "Trailing unbounded should have upper time bound (<=), got: {sql}" + ); + assert!( + !sql.contains(r#"created_at >= $_"#), "Trailing unbounded should not have a lower time bound (>=), got: {sql}" ); assert!( @@ -61,7 +65,11 @@ async fn test_rolling_window_leading_unbounded_no_granularity() { .expect("Should generate SQL for leading unbounded"); assert!( - !sql.contains("<= $_1_$"), + sql.contains(r#""test_cube".created_at >= $_0_$::timestamptz"#), + "Leading unbounded should have lower time bound (>=), got: {sql}" + ); + assert!( + !sql.contains(r#"created_at <= $_"#), "Leading unbounded should not have an upper time bound (<=), got: {sql}" ); @@ -92,12 +100,12 @@ async fn test_rolling_window_both_unbounded_no_granularity() { .expect("Should generate SQL for both unbounded"); assert!( - !sql.contains(">= $_0_$"), - "Both unbounded should not have a lower time bound (>=), got: {sql}" + sql.contains(r#""test_cube".val"#), + "Should reference the measure column, got: {sql}" ); assert!( - !sql.contains("<= $_1_$"), - "Both unbounded should not have an upper time bound (<=), got: {sql}" + !sql.contains("WHERE"), + "Both unbounded should not have WHERE clause, got: {sql}" ); if let Some(result) = test_context @@ -128,7 +136,11 @@ async fn test_rolling_window_trailing_unbounded_with_granularity() { .expect("Should generate SQL for trailing unbounded with granularity"); assert!( - !sql.contains(">= \"time_series\".\"date_from\""), + sql.contains("time_series"), + "With granularity should reference time_series CTE, got: {sql}" + ); + assert!( + !sql.contains(r#">= "time_series"."date_from""#), "JOIN should not have lower bound with trailing unbounded, got: {sql}" ); @@ -139,3 +151,196 @@ async fn test_rolling_window_trailing_unbounded_with_granularity() { insta::assert_snapshot!(result); } } + +#[tokio::test(flavor = "multi_thread")] +async fn test_rolling_window_bounded_no_granularity() { + let test_context = create_context(); + + let query_yaml = indoc! {r#" + measures: + - test_cube.val_bounded + time_dimensions: + - dimension: test_cube.created_at + dateRange: + - "2025-10-07" + - "2025-10-08" + "#}; + + let sql = test_context + .build_sql(query_yaml) + .expect("Should generate SQL for bounded rolling window"); + + assert!( + !sql.contains("time_series"), + "Without granularity should not reference time_series CTE, got: {sql}" + ); + assert!( + sql.contains(r#"created_at >= $_0_$::timestamptz"#) + && sql.contains(r#"created_at <= $_1_$::timestamptz"#), + "Should use parameterized date range on created_at, got: {sql}" + ); + + if let Some(result) = test_context + .try_execute_pg(query_yaml, "rolling_window_tables.sql") + .await + { + insta::assert_snapshot!(result); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_rolling_window_bounded_with_granularity() { + let test_context = create_context(); + + let query_yaml = indoc! {r#" + measures: + - test_cube.val_bounded + time_dimensions: + - dimension: test_cube.created_at + granularity: day + dateRange: + - "2025-10-07" + - "2025-10-08" + "#}; + + let sql = test_context + .build_sql(query_yaml) + .expect("Should generate SQL for bounded rolling window with granularity"); + + assert!( + sql.contains("- interval '3 day'"), + "Should subtract trailing interval '3 day', got: {sql}" + ); + assert!( + sql.contains("+ interval '1 day'"), + "Should add leading interval '1 day', got: {sql}" + ); + assert!( + sql.contains("AT TIME ZONE 'UTC'"), + "Should apply convert_tz, got: {sql}" + ); + + if let Some(result) = test_context + .try_execute_pg(query_yaml, "rolling_window_tables.sql") + .await + { + insta::assert_snapshot!(result); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_rolling_window_trailing_bounded_no_granularity() { + let test_context = create_context(); + + let query_yaml = indoc! {r#" + measures: + - test_cube.val_trailing_bounded + time_dimensions: + - dimension: test_cube.created_at + dateRange: + - "2025-10-07" + - "2025-10-08" + "#}; + + let sql = test_context + .build_sql(query_yaml) + .expect("Should generate SQL for trailing bounded rolling window"); + + // Without granularity, trailing bounded falls back to plain inDateRange + assert!( + sql.contains(r#"created_at >= $_0_$::timestamptz"#) + && sql.contains(r#"created_at <= $_1_$::timestamptz"#), + "Should use parameterized date range on created_at, got: {sql}" + ); + assert!( + !sql.contains("time_series"), + "Without granularity should not reference time_series CTE, got: {sql}" + ); + assert!( + !sql.contains("interval"), + "Without granularity should not have interval arithmetic, got: {sql}" + ); + + if let Some(result) = test_context + .try_execute_pg(query_yaml, "rolling_window_tables.sql") + .await + { + insta::assert_snapshot!(result); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_rolling_window_trailing_bounded_with_granularity() { + let test_context = create_context(); + + let query_yaml = indoc! {r#" + measures: + - test_cube.val_trailing_bounded + time_dimensions: + - dimension: test_cube.created_at + granularity: day + dateRange: + - "2025-10-07" + - "2025-10-08" + "#}; + + let sql = test_context + .build_sql(query_yaml) + .expect("Should generate SQL for trailing bounded with granularity"); + + assert!( + sql.contains("- interval '7 day'"), + "Should subtract trailing interval '7 day', got: {sql}" + ); + assert!( + !sql.contains("+ interval"), + "Should not have leading interval (only trailing), got: {sql}" + ); + assert!( + sql.contains("AT TIME ZONE 'UTC'"), + "Should apply convert_tz, got: {sql}" + ); + + if let Some(result) = test_context + .try_execute_pg(query_yaml, "rolling_window_tables.sql") + .await + { + insta::assert_snapshot!(result); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_rolling_window_to_date_with_granularity() { + let test_context = create_context(); + + let query_yaml = indoc! {r#" + measures: + - test_cube.val_to_date + time_dimensions: + - dimension: test_cube.created_at + granularity: day + dateRange: + - "2025-10-07" + - "2025-10-08" + "#}; + + let sql = test_context + .build_sql(query_yaml) + .expect("Should generate SQL for to_date rolling window"); + + assert!( + sql.contains("date_trunc('month'"), + "To_date should apply month granularity truncation, got: {sql}" + ); + assert!( + sql.contains("AT TIME ZONE 'UTC'"), + "Should apply convert_tz, got: {sql}" + ); + + if let Some(result) = test_context + .try_execute_pg(query_yaml, "rolling_window_tables.sql") + .await + { + insta::assert_snapshot!(result); + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_no_granularity.snap b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_no_granularity.snap new file mode 100644 index 0000000000000..6aa893a1eba0a --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_no_granularity.snap @@ -0,0 +1,7 @@ +--- +source: cubesqlplanner/src/tests/rolling_window_sql_generation.rs +expression: result +--- +test_cube__val_bounded +---------------------- +120.00 diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_with_granularity.snap b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_with_granularity.snap new file mode 100644 index 0000000000000..10209c21bd8f5 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_bounded_with_granularity.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/src/tests/rolling_window_sql_generation.rs +expression: result +--- +test_cube__created_at_day | test_cube__val_bounded +--------------------------+----------------------- +2025-10-07 00:00:00 | 150.00 +2025-10-08 00:00:00 | 200.00 diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_to_date_with_granularity.snap b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_to_date_with_granularity.snap new file mode 100644 index 0000000000000..eed5217ac452c --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_to_date_with_granularity.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/src/tests/rolling_window_sql_generation.rs +expression: result +--- +test_cube__created_at_day | test_cube__val_to_date +--------------------------+----------------------- +2025-10-07 00:00:00 | 100.00 +2025-10-08 00:00:00 | 150.00 diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_no_granularity.snap b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_no_granularity.snap new file mode 100644 index 0000000000000..a28f82f9a0249 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_no_granularity.snap @@ -0,0 +1,7 @@ +--- +source: cubesqlplanner/src/tests/rolling_window_sql_generation.rs +expression: result +--- +test_cube__val_trailing_bounded +------------------------------- +120.00 diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_with_granularity.snap b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_with_granularity.snap new file mode 100644 index 0000000000000..39cea56a5dbf5 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/snapshots/cubesqlplanner__tests__rolling_window_sql_generation__rolling_window_trailing_bounded_with_granularity.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/src/tests/rolling_window_sql_generation.rs +expression: result +--- +test_cube__created_at_day | test_cube__val_trailing_bounded +--------------------------+-------------------------------- +2025-10-07 00:00:00 | 100.00 +2025-10-08 00:00:00 | 150.00