diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs index ae165d77ae179..05effb43fb170 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct TimeDimension { pub dimension: String, pub granularity: Option, @@ -21,7 +21,7 @@ pub struct TimeDimension { pub date_range: Option>, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct FilterItem { pub or: Option>, pub and: Option>, @@ -31,7 +31,7 @@ pub struct FilterItem { pub values: Option>>, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct OrderByItem { pub id: String, pub desc: Option, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/options_member.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/options_member.rs index 88565dbc574d4..72075a3a89cdf 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/options_member.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/options_member.rs @@ -11,6 +11,15 @@ pub enum OptionsMember { MemberExpression(Rc), } +impl Clone for OptionsMember { + fn clone(&self) -> Self { + match self { + Self::MemberName(name) => Self::MemberName(name.clone()), + Self::MemberExpression(expr) => Self::MemberExpression(expr.clone()), + } + } +} + impl NativeDeserialize for OptionsMember { fn from_native(native_object: NativeObjectHandle) -> Result { match String::from_native(native_object.clone()) { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs new file mode 100644 index 0000000000000..b992ea7d4c59e --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs @@ -0,0 +1,189 @@ +use std::any::Any; +use std::rc::Rc; + +use cubenativeutils::CubeError; +use typed_builder::TypedBuilder; + +use crate::{ + cube_bridge::{ + base_query_options::{ + BaseQueryOptions, BaseQueryOptionsStatic, FilterItem, OrderByItem, TimeDimension, + }, + base_tools::BaseTools, + evaluator::CubeEvaluator, + join_graph::JoinGraph, + join_hints::JoinHintItem, + options_member::OptionsMember, + security_context::SecurityContext, + }, + impl_static_data, +}; + +/// Mock implementation of BaseQueryOptions for testing +#[derive(TypedBuilder, Clone)] +pub struct MockBaseQueryOptions { + // Required fields - dependencies that must be provided + cube_evaluator: Rc, + base_tools: Rc, + join_graph: Rc, + security_context: Rc, + + // Optional fields from trait methods + #[builder(default)] + measures: Option>, + #[builder(default)] + dimensions: Option>, + #[builder(default)] + segments: Option>, + #[builder(default)] + join_hints: Option>, + + // Fields from BaseQueryOptionsStatic + #[builder(default)] + time_dimensions: Option>, + #[builder(default)] + timezone: Option, + #[builder(default)] + filters: Option>, + #[builder(default)] + order: Option>, + #[builder(default)] + limit: Option, + #[builder(default)] + row_limit: Option, + #[builder(default)] + offset: Option, + #[builder(default)] + ungrouped: Option, + #[builder(default = false)] + export_annotated_sql: bool, + #[builder(default)] + pre_aggregation_query: Option, + #[builder(default)] + total_query: Option, + #[builder(default)] + cubestore_support_multistage: Option, + #[builder(default = false)] + disable_external_pre_aggregations: bool, +} + +impl_static_data!( + MockBaseQueryOptions, + BaseQueryOptionsStatic, + time_dimensions, + timezone, + filters, + order, + limit, + row_limit, + offset, + ungrouped, + export_annotated_sql, + pre_aggregation_query, + total_query, + cubestore_support_multistage, + disable_external_pre_aggregations +); + +pub fn members_from_strings(strings: Vec) -> Vec { + strings + .into_iter() + .map(|s| OptionsMember::MemberName(s.to_string())) + .collect() +} + +#[allow(dead_code)] +pub fn filter_item( + member: M, + operator: O, + values: Vec, +) -> FilterItem { + FilterItem { + member: Some(member.to_string()), + dimension: None, + operator: Some(operator.to_string()), + values: Some(values.into_iter().map(|v| Some(v.to_string())).collect()), + or: None, + and: None, + } +} + +#[allow(dead_code)] +pub fn filter_or(items: Vec) -> FilterItem { + FilterItem { + or: Some(items), + member: None, + dimension: None, + operator: None, + values: None, + and: None, + } +} + +#[allow(dead_code)] +pub fn filter_and(items: Vec) -> FilterItem { + FilterItem { + and: Some(items), + member: None, + dimension: None, + operator: None, + values: None, + or: None, + } +} + +impl BaseQueryOptions for MockBaseQueryOptions { + crate::impl_static_data_method!(BaseQueryOptionsStatic); + + fn has_measures(&self) -> Result { + Ok(self.measures.is_some()) + } + + fn measures(&self) -> Result>, CubeError> { + Ok(self.measures.clone()) + } + + fn has_dimensions(&self) -> Result { + Ok(self.dimensions.is_some()) + } + + fn dimensions(&self) -> Result>, CubeError> { + Ok(self.dimensions.clone()) + } + + fn has_segments(&self) -> Result { + Ok(self.segments.is_some()) + } + + fn segments(&self) -> Result>, CubeError> { + Ok(self.segments.clone()) + } + + fn cube_evaluator(&self) -> Result, CubeError> { + Ok(self.cube_evaluator.clone()) + } + + fn base_tools(&self) -> Result, CubeError> { + Ok(self.base_tools.clone()) + } + + fn join_graph(&self) -> Result, CubeError> { + Ok(self.join_graph.clone()) + } + + fn security_context(&self) -> Result, CubeError> { + Ok(self.security_context.clone()) + } + + fn has_join_hints(&self) -> Result { + Ok(self.join_hints.is_some()) + } + + fn join_hints(&self) -> Result>, CubeError> { + Ok(self.join_hints.clone()) + } + + fn as_any(self: Rc) -> Rc { + self + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs index d8fb8d1d84387..fe935eefaa260 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -1,8 +1,9 @@ #[macro_use] mod macros; -mod yaml; +pub mod yaml; +pub mod base_query_options; mod mock_base_tools; mod mock_case_definition; mod mock_case_else_item; @@ -33,6 +34,7 @@ mod mock_sql_utils; mod mock_struct_with_sql_member; mod mock_timeshift_definition; +pub use base_query_options::{members_from_strings, MockBaseQueryOptions}; pub use mock_base_tools::MockBaseTools; pub use mock_case_definition::MockCaseDefinition; pub use mock_case_else_item::MockCaseElseItem; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/base_query_options.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/base_query_options.rs new file mode 100644 index 0000000000000..61a73de96cbe6 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/base_query_options.rs @@ -0,0 +1,136 @@ +use crate::cube_bridge::base_query_options::{FilterItem, OrderByItem}; +use serde::de; +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Deserialize)] +pub struct YamlBaseQueryOptions { + #[serde(default)] + pub measures: Option>, + #[serde(default)] + pub dimensions: Option>, + #[serde(default)] + pub segments: Option>, + #[serde(default)] + pub order: Option>, + #[serde(default)] + pub filters: Option>, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub row_limit: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub ungrouped: Option, + #[serde(default)] + pub export_annotated_sql: Option, + #[serde(default)] + pub pre_aggregation_query: Option, + #[serde(default)] + pub total_query: Option, + #[serde(default)] + pub cubestore_support_multistage: Option, + #[serde(default)] + pub disable_external_pre_aggregations: Option, +} + +#[derive(Debug, Deserialize)] +pub struct YamlOrderByItem { + pub id: String, + #[serde(default)] + pub desc: Option, +} + +impl YamlOrderByItem { + pub fn into_order_by_item(self) -> OrderByItem { + OrderByItem { + id: self.id, + desc: self.desc, + } + } +} + +#[derive(Debug)] +pub enum YamlFilterItem { + Group(YamlFilterGroup), + Base(YamlBaseFilter), +} + +impl<'de> Deserialize<'de> for YamlFilterItem { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_yaml::Value::deserialize(deserializer)?; + + // Check if it has 'or' or 'and' keys - then it's a Group + if let serde_yaml::Value::Mapping(ref map) = value { + let has_or = map.contains_key(serde_yaml::Value::String("or".to_string())); + let has_and = map.contains_key(serde_yaml::Value::String("and".to_string())); + + if has_or || has_and { + return serde_yaml::from_value::(value) + .map(YamlFilterItem::Group) + .map_err(de::Error::custom); + } + } + + // Otherwise it's a Base filter + serde_yaml::from_value::(value) + .map(YamlFilterItem::Base) + .map_err(de::Error::custom) + } +} + +#[derive(Debug, Deserialize)] +pub struct YamlFilterGroup { + #[serde(default)] + pub or: Option>, + #[serde(default)] + pub and: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct YamlBaseFilter { + #[serde(default)] + pub member: Option, + #[serde(default)] + pub dimension: Option, + #[serde(default)] + pub operator: Option, + #[serde(default)] + pub values: Option>>, +} + +impl YamlFilterItem { + pub fn into_filter_item(self) -> FilterItem { + match self { + YamlFilterItem::Group(group) => FilterItem { + or: group.or.map(|items| { + items + .into_iter() + .map(|item| item.into_filter_item()) + .collect() + }), + and: group.and.map(|items| { + items + .into_iter() + .map(|item| item.into_filter_item()) + .collect() + }), + member: None, + dimension: None, + operator: None, + values: None, + }, + YamlFilterItem::Base(base) => FilterItem { + or: None, + and: None, + member: base.member, + dimension: base.dimension, + operator: base.operator, + values: base.values, + }, + } + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/mod.rs index 91ec14ad9cf37..ce7dabd02bbf6 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/mod.rs @@ -1,3 +1,4 @@ +pub mod base_query_options; pub mod case; pub mod dimension; pub mod measure; @@ -5,6 +6,7 @@ pub mod schema; pub mod segment; pub mod timeshift; +pub use base_query_options::YamlBaseQueryOptions; pub use dimension::YamlDimensionDefinition; pub use measure::YamlMeasureDefinition; pub use schema::YamlSchema; 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 8c86ee77e5b0c..d869c50cfa6e2 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 @@ -1,8 +1,12 @@ +use crate::cube_bridge::base_query_options::BaseQueryOptions; use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::sql_nodes::SqlNodesFactory; use crate::planner::sql_evaluator::{MemberSymbol, SqlEvaluatorVisitor}; use crate::planner::sql_templates::PlanSqlTemplates; -use crate::test_fixtures::cube_bridge::{MockSchema, MockSecurityContext}; +use crate::test_fixtures::cube_bridge::yaml::YamlBaseQueryOptions; +use crate::test_fixtures::cube_bridge::{ + members_from_strings, MockBaseQueryOptions, MockSchema, MockSecurityContext, +}; use chrono_tz::Tz; use cubenativeutils::CubeError; use std::rc::Rc; @@ -10,6 +14,7 @@ use std::rc::Rc; /// Test context providing query tools and symbol creation helpers pub struct TestContext { query_tools: Rc, + security_context: Rc, } impl TestContext { @@ -26,14 +31,17 @@ impl TestContext { let query_tools = QueryTools::try_new( evaluator, - security_context, + security_context.clone(), Rc::new(base_tools), join_graph, Some(timezone.to_string()), false, // export_annotated_sql )?; - Ok(Self { query_tools }) + Ok(Self { + query_tools, + security_context, + }) } #[allow(dead_code)] @@ -72,4 +80,247 @@ impl TestContext { visitor.apply(symbol, node_processor, &templates) } + + /// Creates MockBaseQueryOptions from YAML string + /// + /// The YAML structure should match the JS query format: + /// ```yaml + /// measures: + /// - visitors.visitor_count + /// dimensions: + /// - visitors.source + /// order: + /// - id: visitors.visitor_count + /// desc: true + /// filters: + /// - or: + /// - dimension: visitors.visitor_count + /// operator: gt + /// values: + /// - "1" + /// - dimension: visitors.source + /// operator: equals + /// values: + /// - google + /// - dimension: visitors.created_at + /// operator: gte + /// values: + /// - "2024-01-01" + /// limit: "100" + /// offset: "20" + /// ungrouped: true + /// ``` + /// + /// Panics if YAML cannot be parsed. + pub fn create_query_options_from_yaml(&self, yaml: &str) -> Rc { + let yaml_options: YamlBaseQueryOptions = serde_yaml::from_str(yaml) + .unwrap_or_else(|e| panic!("Failed to parse YAML query options: {}", e)); + + let measures = yaml_options + .measures + .map(|m| members_from_strings(m)) + .filter(|m| !m.is_empty()); + + let dimensions = yaml_options + .dimensions + .map(|d| members_from_strings(d)) + .filter(|d| !d.is_empty()); + + let segments = yaml_options + .segments + .map(|s| members_from_strings(s)) + .filter(|s| !s.is_empty()); + + let order = yaml_options + .order + .map(|items| { + items + .into_iter() + .map(|item| item.into_order_by_item()) + .collect::>() + }) + .filter(|o| !o.is_empty()); + + let filters = yaml_options + .filters + .map(|items| { + items + .into_iter() + .map(|item| item.into_filter_item()) + .collect::>() + }) + .filter(|f| !f.is_empty()); + + Rc::new( + MockBaseQueryOptions::builder() + .cube_evaluator(self.query_tools.cube_evaluator().clone()) + .base_tools(self.query_tools.base_tools().clone()) + .join_graph(self.query_tools.join_graph().clone()) + .security_context(self.security_context.clone()) + .measures(measures) + .dimensions(dimensions) + .segments(segments) + .order(order) + .filters(filters) + .limit(yaml_options.limit) + .row_limit(yaml_options.row_limit) + .offset(yaml_options.offset) + .ungrouped(yaml_options.ungrouped) + .export_annotated_sql(yaml_options.export_annotated_sql.unwrap_or(false)) + .pre_aggregation_query(yaml_options.pre_aggregation_query) + .total_query(yaml_options.total_query) + .cubestore_support_multistage(yaml_options.cubestore_support_multistage) + .disable_external_pre_aggregations( + yaml_options + .disable_external_pre_aggregations + .unwrap_or(false), + ) + .build(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_fixtures::cube_bridge::MockSchema; + + #[test] + fn test_yaml_filter_parsing() { + use indoc::indoc; + + let yaml = indoc! {" + filters: + - or: + - dimension: visitors.count + operator: gt + values: + - \"1\" + - dimension: visitors.source + operator: equals + values: + - google + - dimension: visitors.created_at + operator: gte + values: + - \"2024-01-01\" + "}; + let parsed: YamlBaseQueryOptions = serde_yaml::from_str(yaml).unwrap(); + let filters = parsed.filters.unwrap(); + + println!("Filter count: {}", filters.len()); + for (i, filter) in filters.iter().enumerate() { + println!("Filter {}: {:?}", i, filter); + } + + assert_eq!(filters.len(), 2); + } + + #[test] + fn test_create_query_options_from_yaml() { + use indoc::indoc; + + let schema = MockSchema::from_yaml_file("common/visitors.yaml"); + let ctx = TestContext::new(schema).unwrap(); + + let yaml = indoc! {" + measures: + - visitors.count + dimensions: + - visitors.source + order: + - id: visitors.count + desc: true + filters: + - or: + - dimension: visitors.count + operator: gt + values: + - \"1\" + - dimension: visitors.source + operator: equals + values: + - google + - dimension: visitors.created_at + operator: gte + values: + - \"2024-01-01\" + limit: \"100\" + offset: \"20\" + ungrouped: true + "}; + + let options = ctx.create_query_options_from_yaml(yaml); + + // Verify measures + let measures = options.measures().unwrap().unwrap(); + assert_eq!(measures.len(), 1); + + // Verify dimensions + let dimensions = options.dimensions().unwrap().unwrap(); + assert_eq!(dimensions.len(), 1); + + // Verify order and filters from static_data + let static_data = options.static_data(); + + let order = static_data.order.as_ref().unwrap(); + assert_eq!(order.len(), 1); + assert_eq!(order[0].id, "visitors.count"); + assert!(order[0].is_desc()); + + let filters = static_data.filters.as_ref().unwrap(); + assert_eq!(filters.len(), 2, "Should have 2 filters"); + + assert!(filters[0].or.is_some(), "First filter should have 'or'"); + assert!( + filters[0].and.is_none(), + "First filter should not have 'and'" + ); + + assert!( + filters[1].or.is_none(), + "Second filter should not have 'or': {:?}", + filters[1].or + ); + assert!( + filters[1].and.is_none(), + "Second filter should not have 'and': {:?}", + filters[1].and + ); + assert!( + filters[1].dimension.is_some(), + "Second filter: member={:?}, dimension={:?}, operator={:?}, values={:?}", + filters[1].member, + filters[1].dimension, + filters[1].operator, + filters[1].values + ); + + // Verify other fields + assert_eq!(static_data.limit, Some("100".to_string())); + assert_eq!(static_data.offset, Some("20".to_string())); + assert_eq!(static_data.ungrouped, Some(true)); + } + + #[test] + fn test_create_query_options_minimal() { + let schema = MockSchema::from_yaml_file("common/visitors.yaml"); + let ctx = TestContext::new(schema).unwrap(); + + let yaml = r#" +measures: + - visitors.count +"#; + + let options = ctx.create_query_options_from_yaml(yaml); + let measures = options.measures().unwrap().unwrap(); + assert_eq!(measures.len(), 1); + + // All other fields should be None/empty + assert!(options.dimensions().unwrap().is_none()); + + let static_data = options.static_data(); + assert!(static_data.order.is_none()); + assert!(static_data.filters.is_none()); + } }