From bc68c8f025ff9952f9b6d7a4ebf0bf0ce55c208c Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Fri, 14 Nov 2025 23:37:18 +0100 Subject: [PATCH 01/13] chore(tesseract): Join Graph mock --- .../cube_bridge/mock_cube_definition.rs | 117 ++++++++- .../cube_bridge/mock_join_item_definition.rs | 2 +- .../test_fixtures/cube_bridge/mock_schema.rs | 223 +++++++++++++++++- 3 files changed, 337 insertions(+), 5 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs index 9a00a52e43ae4..0772568569f5b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs @@ -1,9 +1,10 @@ use crate::cube_bridge::cube_definition::{CubeDefinition, CubeDefinitionStatic}; use crate::cube_bridge::member_sql::MemberSql; use crate::impl_static_data; -use crate::test_fixtures::cube_bridge::MockMemberSql; +use crate::test_fixtures::cube_bridge::{MockJoinItemDefinition, MockMemberSql}; use cubenativeutils::CubeError; use std::any::Any; +use std::collections::HashMap; use std::rc::Rc; use typed_builder::TypedBuilder; @@ -26,6 +27,10 @@ pub struct MockCubeDefinition { sql_table: Option, #[builder(default, setter(strip_option))] sql: Option, + + // Joins field for mock testing + #[builder(default)] + joins: HashMap, } impl_static_data!( @@ -68,9 +73,23 @@ impl CubeDefinition for MockCubeDefinition { } } +impl MockCubeDefinition { + /// Get all joins for this cube + pub fn joins(&self) -> &HashMap { + &self.joins + } + + /// Get a specific join by name + pub fn get_join(&self, name: &str) -> Option<&MockJoinItemDefinition> { + self.joins.get(name) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::cube_bridge::join_item_definition::JoinItemDefinition; + use std::collections::HashMap; #[test] fn test_basic_cube() { @@ -170,4 +189,100 @@ mod tests { let sql_table = cube.sql_table().unwrap().unwrap(); assert_eq!(sql_table.args_names(), &vec!["database"]); } + + #[test] + fn test_cube_with_single_join() { + let mut joins = HashMap::new(); + joins.insert( + "users".to_string(), + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ); + + let cube = MockCubeDefinition::builder() + .name("orders".to_string()) + .sql_table("public.orders".to_string()) + .joins(joins) + .build(); + + assert_eq!(cube.joins().len(), 1); + assert!(cube.get_join("users").is_some()); + + let users_join = cube.get_join("users").unwrap(); + assert_eq!(users_join.static_data().relationship, "many_to_one"); + } + + #[test] + fn test_cube_with_multiple_joins() { + let mut joins = HashMap::new(); + joins.insert( + "users".to_string(), + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ); + joins.insert( + "products".to_string(), + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.product_id = {products.id}".to_string()) + .build(), + ); + + let cube = MockCubeDefinition::builder() + .name("orders".to_string()) + .sql_table("public.orders".to_string()) + .joins(joins) + .build(); + + assert_eq!(cube.joins().len(), 2); + assert!(cube.get_join("users").is_some()); + assert!(cube.get_join("products").is_some()); + assert!(cube.get_join("nonexistent").is_none()); + } + + #[test] + fn test_join_accessor_methods() { + let mut joins = HashMap::new(); + joins.insert( + "countries".to_string(), + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.country_id = {countries.id}".to_string()) + .build(), + ); + + let cube = MockCubeDefinition::builder() + .name("users".to_string()) + .sql_table("public.users".to_string()) + .joins(joins) + .build(); + + // Test joins() method + let all_joins = cube.joins(); + assert_eq!(all_joins.len(), 1); + assert!(all_joins.contains_key("countries")); + + // Test get_join() method + let country_join = cube.get_join("countries").unwrap(); + let sql = country_join.sql().unwrap(); + assert_eq!(sql.args_names(), &vec!["CUBE", "countries"]); + + // Test nonexistent join + assert!(cube.get_join("nonexistent").is_none()); + } + + #[test] + fn test_cube_without_joins() { + let cube = MockCubeDefinition::builder() + .name("users".to_string()) + .sql_table("public.users".to_string()) + .build(); + + assert_eq!(cube.joins().len(), 0); + assert!(cube.get_join("any").is_none()); + } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs index fe138a6fafe6f..83e98e80598a5 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use typed_builder::TypedBuilder; /// Mock implementation of JoinItemDefinition for testing -#[derive(TypedBuilder)] +#[derive(Clone, TypedBuilder)] pub struct MockJoinItemDefinition { // Fields from JoinItemDefinitionStatic relationship: String, 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 177971315c2ce..b88cdf1747115 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 @@ -1,6 +1,6 @@ use crate::test_fixtures::cube_bridge::{ - MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockMeasureDefinition, - MockSegmentDefinition, + MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockJoinItemDefinition, + MockMeasureDefinition, MockSegmentDefinition, }; use std::collections::HashMap; use std::rc::Rc; @@ -119,6 +119,7 @@ impl MockSchemaBuilder { measures: HashMap::new(), dimensions: HashMap::new(), segments: HashMap::new(), + joins: HashMap::new(), } } @@ -154,6 +155,7 @@ pub struct MockCubeBuilder { measures: HashMap>, dimensions: HashMap>, segments: HashMap>, + joins: HashMap, } impl MockCubeBuilder { @@ -193,9 +195,15 @@ impl MockCubeBuilder { self } + /// Add a join to the cube + pub fn add_join(mut self, name: impl Into, definition: MockJoinItemDefinition) -> Self { + self.joins.insert(name.into(), definition); + self + } + /// Finish building this cube and return to schema builder pub fn finish_cube(mut self) -> MockSchemaBuilder { - let cube_def = self.cube_definition.unwrap_or_else(|| { + let mut cube_def = self.cube_definition.unwrap_or_else(|| { // Create default cube definition with the cube name MockCubeDefinition::builder() .name(self.cube_name.clone()) @@ -203,6 +211,21 @@ impl MockCubeBuilder { .build() }); + // Merge joins from builder with joins from cube definition + let mut all_joins = cube_def.joins().clone(); + all_joins.extend(self.joins); + + // Rebuild cube definition with merged joins + let static_data = cube_def.static_data(); + cube_def = MockCubeDefinition::builder() + .name(static_data.name.clone()) + .sql_alias(static_data.sql_alias.clone()) + .is_view(static_data.is_view) + .is_calendar(static_data.is_calendar) + .join_map(static_data.join_map.clone()) + .joins(all_joins) + .build(); + let cube = MockCube { definition: cube_def, measures: self.measures, @@ -400,6 +423,7 @@ impl MockViewBuilder { mod tests { use super::*; use crate::cube_bridge::dimension_definition::DimensionDefinition; + use crate::cube_bridge::join_item_definition::JoinItemDefinition; use crate::cube_bridge::measure_definition::MeasureDefinition; use crate::cube_bridge::segment_definition::SegmentDefinition; @@ -1102,4 +1126,197 @@ mod tests { .finish_view() .build(); } + + #[test] + fn test_schema_builder_with_joins() { + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_dimension( + "user_id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("user_id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + // Verify cubes exist + assert!(schema.get_cube("users").is_some()); + assert!(schema.get_cube("orders").is_some()); + + // Verify join in orders cube + let orders_cube = schema.get_cube("orders").unwrap(); + assert_eq!(orders_cube.definition.joins().len(), 1); + assert!(orders_cube.definition.get_join("users").is_some()); + + let users_join = orders_cube.definition.get_join("users").unwrap(); + assert_eq!(users_join.static_data().relationship, "many_to_one"); + } + + #[test] + fn test_complex_schema_with_join_relationships() { + let schema = MockSchemaBuilder::new() + .add_cube("countries") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_dimension( + "name", + MockDimensionDefinition::builder() + .dimension_type("string".to_string()) + .sql("name".to_string()) + .build(), + ) + .finish_cube() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_dimension( + "country_id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("country_id".to_string()) + .build(), + ) + .add_join( + "countries", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.country_id = {countries.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_dimension( + "user_id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("user_id".to_string()) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + // Verify all cubes exist + assert_eq!(schema.cube_names().len(), 3); + + // Verify countries has no joins + let countries_cube = schema.get_cube("countries").unwrap(); + assert_eq!(countries_cube.definition.joins().len(), 0); + + // Verify users has join to countries + let users_cube = schema.get_cube("users").unwrap(); + assert_eq!(users_cube.definition.joins().len(), 1); + assert!(users_cube.definition.get_join("countries").is_some()); + + // Verify orders has join to users + let orders_cube = schema.get_cube("orders").unwrap(); + assert_eq!(orders_cube.definition.joins().len(), 1); + assert!(orders_cube.definition.get_join("users").is_some()); + + // Verify join SQL + let orders_users_join = orders_cube.definition.get_join("users").unwrap(); + let sql = orders_users_join.sql().unwrap(); + assert_eq!(sql.args_names(), &vec!["CUBE", "users"]); + } + + #[test] + fn test_cube_with_multiple_joins_via_builder() { + let schema = MockSchemaBuilder::new() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .add_join( + "products", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.product_id = {products.id}".to_string()) + .build(), + ) + .add_join( + "warehouses", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.warehouse_id = {warehouses.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let orders_cube = schema.get_cube("orders").unwrap(); + assert_eq!(orders_cube.definition.joins().len(), 3); + assert!(orders_cube.definition.get_join("users").is_some()); + assert!(orders_cube.definition.get_join("products").is_some()); + assert!(orders_cube.definition.get_join("warehouses").is_some()); + } } From 7d2ea35c60084cd5c5b0183e9bb3861526dccb55 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Fri, 14 Nov 2025 23:50:04 +0100 Subject: [PATCH 02/13] JoinEdge --- .../cube_bridge/mock_join_graph.rs | 195 +++++++++++++++++- .../cube_bridge/mock_join_item_definition.rs | 2 +- .../src/test_fixtures/cube_bridge/mod.rs | 3 +- .../tests/cube_evaluator/symbol_evaluator.rs | 2 +- 4 files changed, 191 insertions(+), 11 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index 808ec59c3a82b..c465b683383cb 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -1,24 +1,144 @@ use crate::cube_bridge::join_definition::JoinDefinition; use crate::cube_bridge::join_graph::JoinGraph; use crate::cube_bridge::join_hints::JoinHintItem; +use crate::test_fixtures::cube_bridge::{MockJoinDefinition, MockJoinItemDefinition}; use cubenativeutils::CubeError; use std::any::Any; +use std::collections::HashMap; use std::rc::Rc; +/// Represents an edge in the join graph +/// +/// Each edge represents a join relationship between two cubes, including both +/// the current routing (from/to) and the original cube names (original_from/original_to). +/// This distinction is important when dealing with cube aliases. +/// +/// # Example +/// +/// ``` +/// use cubesqlplanner::test_fixtures::cube_bridge::{JoinEdge, MockJoinItemDefinition}; +/// use std::rc::Rc; +/// +/// let join_def = Rc::new( +/// MockJoinItemDefinition::builder() +/// .relationship("many_to_one".to_string()) +/// .sql("{orders.user_id} = {users.id}".to_string()) +/// .build() +/// ); +/// +/// let edge = JoinEdge { +/// join: join_def, +/// from: "orders".to_string(), +/// to: "users".to_string(), +/// original_from: "Orders".to_string(), +/// original_to: "Users".to_string(), +/// }; +/// +/// assert_eq!(edge.from, "orders"); +/// assert_eq!(edge.original_from, "Orders"); +/// ``` +#[derive(Debug, Clone)] +pub struct JoinEdge { + /// The join definition containing the relationship and SQL + pub join: Rc, + /// The current source cube name (may be an alias) + pub from: String, + /// The current destination cube name (may be an alias) + pub to: String, + /// The original source cube name (without aliases) + pub original_from: String, + /// The original destination cube name (without aliases) + pub original_to: String, +} + /// Mock implementation of JoinGraph for testing /// -/// This mock provides a placeholder implementation. -/// The build_join method is not implemented and will panic with todo!(). +/// This implementation provides a graph-based representation of join relationships +/// between cubes, matching the TypeScript JoinGraph structure from +/// `/packages/cubejs-schema-compiler/src/compiler/JoinGraph.ts`. +/// +/// The graph maintains both directed and undirected representations to support +/// pathfinding and connectivity queries. It also caches built join trees to avoid +/// redundant computation. /// /// # Example /// /// ``` /// use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; /// -/// let join_graph = MockJoinGraph; -/// // Note: calling build_join will panic with todo!() +/// let graph = MockJoinGraph::new(); +/// // Add edges and build joins... /// ``` -pub struct MockJoinGraph; +#[derive(Clone)] +pub struct MockJoinGraph { + /// Directed graph: source -> destination -> weight + /// Represents the directed join relationships between cubes + nodes: HashMap>, + + /// Undirected graph: destination -> source -> weight + /// Used for connectivity checks and pathfinding + undirected_nodes: HashMap>, + + /// Edge lookup: "from-to" -> JoinEdge + /// Maps edge keys to their corresponding join definitions + edges: HashMap, + + /// Cache of built join trees: serialized cubes -> JoinDefinition + /// Stores previously computed join paths for reuse + built_joins: HashMap>, + + /// Cache for connected components + /// Stores the connected component ID for each cube + /// None until first calculation + cached_connected_components: Option>, +} + +impl MockJoinGraph { + /// Creates a new empty join graph + /// + /// # Example + /// + /// ``` + /// use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; + /// + /// let graph = MockJoinGraph::new(); + /// ``` + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + undirected_nodes: HashMap::new(), + edges: HashMap::new(), + built_joins: HashMap::new(), + cached_connected_components: None, + } + } + + /// Creates an edge key from source and destination cube names + /// + /// The key format is "from-to", matching the TypeScript implementation. + /// + /// # Arguments + /// + /// * `from` - Source cube name + /// * `to` - Destination cube name + /// + /// # Example + /// + /// ``` + /// # use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; + /// let key = MockJoinGraph::edge_key("orders", "users"); + /// assert_eq!(key, "orders-users"); + /// ``` + fn edge_key(from: &str, to: &str) -> String { + format!("{}-{}", from, to) + } +} + +impl Default for MockJoinGraph { + fn default() -> Self { + Self::new() + } +} impl JoinGraph for MockJoinGraph { fn as_any(self: Rc) -> Rc { @@ -38,8 +158,67 @@ mod tests { use super::*; #[test] - fn test_can_create() { - let _join_graph = MockJoinGraph; - // Just verify we can create the mock + fn test_mock_join_graph_new() { + let graph = MockJoinGraph::new(); + + // Verify all fields are empty + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); + assert!(graph.edges.is_empty()); + assert!(graph.built_joins.is_empty()); + assert!(graph.cached_connected_components.is_none()); + } + + #[test] + fn test_edge_key_format() { + let key = MockJoinGraph::edge_key("orders", "users"); + assert_eq!(key, "orders-users"); + + let key2 = MockJoinGraph::edge_key("users", "countries"); + assert_eq!(key2, "users-countries"); + + // Verify different order creates different key + let key3 = MockJoinGraph::edge_key("users", "orders"); + assert_ne!(key, key3); + } + + #[test] + fn test_join_edge_creation() { + let join_def = Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ); + + let edge = JoinEdge { + join: join_def.clone(), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "Orders".to_string(), + original_to: "Users".to_string(), + }; + + assert_eq!(edge.from, "orders"); + assert_eq!(edge.to, "users"); + assert_eq!(edge.original_from, "Orders"); + assert_eq!(edge.original_to, "Users"); + assert_eq!(edge.join.static_data().relationship, "many_to_one"); + } + + #[test] + fn test_default_trait() { + let graph = MockJoinGraph::default(); + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); + } + + #[test] + fn test_clone_trait() { + let graph = MockJoinGraph::new(); + let cloned = graph.clone(); + + assert!(cloned.nodes.is_empty()); + assert!(cloned.undirected_nodes.is_empty()); } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs index 83e98e80598a5..b207ebefa7c7a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item_definition.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use typed_builder::TypedBuilder; /// Mock implementation of JoinItemDefinition for testing -#[derive(Clone, TypedBuilder)] +#[derive(Debug, Clone, TypedBuilder)] pub struct MockJoinItemDefinition { // Fields from JoinItemDefinitionStatic relationship: String, 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 4296a6e61abf1..c7e3c3ad99907 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -45,7 +45,8 @@ pub use mock_evaluator::MockCubeEvaluator; pub use mock_expression_struct::MockExpressionStruct; pub use mock_geo_item::MockGeoItem; pub use mock_granularity_definition::MockGranularityDefinition; -pub use mock_join_graph::MockJoinGraph; +pub use mock_join_definition::MockJoinDefinition; +pub use mock_join_graph::{JoinEdge, MockJoinGraph}; pub use mock_join_item::MockJoinItem; pub use mock_join_item_definition::MockJoinItemDefinition; pub use mock_measure_definition::MockMeasureDefinition; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/tests/cube_evaluator/symbol_evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/tests/cube_evaluator/symbol_evaluator.rs index 021e00e3b05eb..e7cd72feb450b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/tests/cube_evaluator/symbol_evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/tests/cube_evaluator/symbol_evaluator.rs @@ -239,7 +239,7 @@ impl SqlEvaluationContext { // Create QueryTools with mocks let security_context = Rc::new(MockSecurityContext); let base_tools = Rc::new(MockBaseTools::builder().build()); - let join_graph = Rc::new(MockJoinGraph); + let join_graph = Rc::new(MockJoinGraph::new()); let query_tools = QueryTools::try_new( evaluator.clone(), From 38f5540c3415f096be277bbca48bb7a7188dec9c Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 00:06:20 +0100 Subject: [PATCH 03/13] petgraph as dev dep --- rust/cubesqlplanner/Cargo.lock | 39 ++ rust/cubesqlplanner/cubesqlplanner/Cargo.toml | 3 + .../src/test_fixtures/graph_utils.rs | 372 ++++++++++++++++++ .../cubesqlplanner/src/test_fixtures/mod.rs | 1 + 4 files changed, 415 insertions(+) create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs diff --git a/rust/cubesqlplanner/Cargo.lock b/rust/cubesqlplanner/Cargo.lock index 368ae95946be8..51f2ff66c728b 100644 --- a/rust/cubesqlplanner/Cargo.lock +++ b/rust/cubesqlplanner/Cargo.lock @@ -278,6 +278,7 @@ dependencies = [ "minijinja", "nativebridge", "neon", + "petgraph", "regex", "serde", "serde_json", @@ -302,6 +303,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "event-listener" version = "5.3.1" @@ -323,6 +330,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -390,6 +403,12 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -655,6 +674,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -906,6 +935,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "phf" version = "0.11.2" diff --git a/rust/cubesqlplanner/cubesqlplanner/Cargo.toml b/rust/cubesqlplanner/cubesqlplanner/Cargo.toml index bf06699756180..fe623e8bfb2fc 100644 --- a/rust/cubesqlplanner/cubesqlplanner/Cargo.toml +++ b/rust/cubesqlplanner/cubesqlplanner/Cargo.toml @@ -22,6 +22,9 @@ lazy_static = "1.4.0" regex = "1.3.9" typed-builder = "0.21.2" +[dev-dependencies] +petgraph = "0.6" + [dependencies.neon] version = "=1" default-features = false diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs new file mode 100644 index 0000000000000..96737a67b1c9a --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs @@ -0,0 +1,372 @@ +//! Graph utilities for MockJoinGraph testing +//! +//! This module provides graph algorithms for testing join path finding in MockJoinGraph. +//! It uses the petgraph library to implement shortest path algorithms (Dijkstra's algorithm +//! via A* with zero heuristic). +//! +//! # Why dev-only +//! +//! This is test infrastructure code that: +//! - Uses petgraph as a dev-dependency (not needed in production) +//! - Converts between our HashMap format and petgraph's graph representation +//! - Provides utilities specifically for testing MockJoinGraph +//! - Is not included in the production binary +//! +//! # Usage +//! +//! ```rust +//! use std::collections::HashMap; +//! use cubesqlplanner::test_fixtures::graph_utils::find_shortest_path; +//! +//! let mut nodes = HashMap::new(); +//! nodes.insert("orders".to_string(), { +//! let mut edges = HashMap::new(); +//! edges.insert("users".to_string(), 1); +//! edges +//! }); +//! nodes.insert("users".to_string(), HashMap::new()); +//! +//! let path = find_shortest_path(&nodes, "orders", "users"); +//! assert_eq!(path, Some(vec!["orders".to_string(), "users".to_string()])); +//! ``` + +use petgraph::graph::NodeIndex; +use petgraph::Graph; +use std::collections::HashMap; + +/// Converts a HashMap-based graph representation to a petgraph directed graph. +/// +/// # Input Format +/// +/// The input is a nested HashMap where: +/// - Outer map keys are cube names (all nodes in the graph) +/// - Inner map represents edges: destination cube name -> edge weight +/// +/// Example: +/// ```rust +/// use std::collections::HashMap; +/// +/// let mut nodes = HashMap::new(); +/// nodes.insert("orders".to_string(), { +/// let mut edges = HashMap::new(); +/// edges.insert("users".to_string(), 1); +/// edges.insert("products".to_string(), 1); +/// edges +/// }); +/// nodes.insert("users".to_string(), HashMap::new()); +/// nodes.insert("products".to_string(), HashMap::new()); +/// ``` +/// +/// # Returns +/// +/// A tuple containing: +/// - Directed graph with cube names as node data and weights as edge data +/// - Mapping from cube name to NodeIndex for quick lookups +/// +/// # Note +/// +/// All cube names that appear in edges must also exist as keys in the outer HashMap. +pub fn build_petgraph_from_hashmap( + nodes: &HashMap>, +) -> (Graph, HashMap) { + let mut graph = Graph::::new(); + let mut node_indices = HashMap::new(); + + // First pass: Add all nodes to the graph + for cube_name in nodes.keys() { + let node_index = graph.add_node(cube_name.clone()); + node_indices.insert(cube_name.clone(), node_index); + } + + // Second pass: Add all edges + for (from_cube, edges) in nodes.iter() { + let from_index = node_indices[from_cube]; + for (to_cube, weight) in edges.iter() { + let to_index = node_indices[to_cube]; + graph.add_edge(from_index, to_index, *weight); + } + } + + (graph, node_indices) +} + +/// Finds the shortest path between two cubes using Dijkstra's algorithm. +/// +/// This function wraps petgraph's A* algorithm with a zero heuristic, which is +/// equivalent to Dijkstra's algorithm. It provides an API similar to node-dijkstra +/// for JavaScript compatibility. +/// +/// # Arguments +/// +/// * `nodes` - Graph representation as nested HashMap (see `build_petgraph_from_hashmap`) +/// * `start` - Name of the starting cube +/// * `end` - Name of the destination cube +/// +/// # Returns +/// +/// - `Some(Vec)` - Path from start to end (inclusive) if a path exists +/// - `None` - If no path exists or if start/end nodes don't exist in the graph +/// +/// # Edge Cases +/// +/// - If `start == end`, returns `Some(vec![start])` +/// - If `start` or `end` don't exist in the graph, returns `None` +/// - If nodes are disconnected, returns `None` +/// +/// # Example +/// +/// ```rust +/// use std::collections::HashMap; +/// use cubesqlplanner::test_fixtures::graph_utils::find_shortest_path; +/// +/// let mut nodes = HashMap::new(); +/// nodes.insert("A".to_string(), { +/// let mut edges = HashMap::new(); +/// edges.insert("B".to_string(), 1); +/// edges +/// }); +/// nodes.insert("B".to_string(), { +/// let mut edges = HashMap::new(); +/// edges.insert("C".to_string(), 1); +/// edges +/// }); +/// nodes.insert("C".to_string(), HashMap::new()); +/// +/// let path = find_shortest_path(&nodes, "A", "C"); +/// assert_eq!(path, Some(vec!["A".to_string(), "B".to_string(), "C".to_string()])); +/// ``` +pub fn find_shortest_path( + nodes: &HashMap>, + start: &str, + end: &str, +) -> Option> { + // Edge case: start == end + if start == end { + return Some(vec![start.to_string()]); + } + + // Edge case: start or end not in graph + if !nodes.contains_key(start) || !nodes.contains_key(end) { + return None; + } + + // Build petgraph from HashMap + let (graph, node_indices) = build_petgraph_from_hashmap(nodes); + + // Get NodeIndex for start and end + let start_index = node_indices[start]; + let end_index = node_indices[end]; + + // Use A* with zero heuristic (equivalent to Dijkstra) + let result = petgraph::algo::astar( + &graph, + start_index, + |n| n == end_index, + |e| *e.weight(), + |_| 0, // Zero heuristic makes this equivalent to Dijkstra + ); + + // Convert result to path of cube names + match result { + Some((_cost, path)) => { + let cube_names: Vec = path + .iter() + .map(|&node_index| graph[node_index].clone()) + .collect(); + Some(cube_names) + } + None => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_path() { + // Graph: A -> B (weight 1) + let mut nodes = HashMap::new(); + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + nodes.insert("A".to_string(), a_edges); + nodes.insert("B".to_string(), HashMap::new()); + + let path = find_shortest_path(&nodes, "A", "B"); + assert_eq!(path, Some(vec!["A".to_string(), "B".to_string()])); + } + + #[test] + fn test_multi_hop_path() { + // Graph: A -> B -> C + let mut nodes = HashMap::new(); + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + nodes.insert("A".to_string(), a_edges); + + let mut b_edges = HashMap::new(); + b_edges.insert("C".to_string(), 1); + nodes.insert("B".to_string(), b_edges); + + nodes.insert("C".to_string(), HashMap::new()); + + let path = find_shortest_path(&nodes, "A", "C"); + assert_eq!( + path, + Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]) + ); + } + + #[test] + fn test_shortest_path_selection() { + // Graph: A -> B -> C (total weight 2) + // A -> D -> C (total weight 5) + let mut nodes = HashMap::new(); + + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + a_edges.insert("D".to_string(), 3); + nodes.insert("A".to_string(), a_edges); + + let mut b_edges = HashMap::new(); + b_edges.insert("C".to_string(), 1); + nodes.insert("B".to_string(), b_edges); + + let mut d_edges = HashMap::new(); + d_edges.insert("C".to_string(), 2); + nodes.insert("D".to_string(), d_edges); + + nodes.insert("C".to_string(), HashMap::new()); + + let path = find_shortest_path(&nodes, "A", "C"); + // Should take the shorter path: A -> B -> C + assert_eq!( + path, + Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]) + ); + } + + #[test] + fn test_disconnected_nodes() { + // Graph: A -> B, C -> D (no connection between them) + let mut nodes = HashMap::new(); + + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + nodes.insert("A".to_string(), a_edges); + + nodes.insert("B".to_string(), HashMap::new()); + + let mut c_edges = HashMap::new(); + c_edges.insert("D".to_string(), 1); + nodes.insert("C".to_string(), c_edges); + + nodes.insert("D".to_string(), HashMap::new()); + + // No path from A to D + let path = find_shortest_path(&nodes, "A", "D"); + assert_eq!(path, None); + } + + #[test] + fn test_same_start_and_end() { + // Graph: A -> B + let mut nodes = HashMap::new(); + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + nodes.insert("A".to_string(), a_edges); + nodes.insert("B".to_string(), HashMap::new()); + + // Path from A to A should be just [A] + let path = find_shortest_path(&nodes, "A", "A"); + assert_eq!(path, Some(vec!["A".to_string()])); + } + + #[test] + fn test_nonexistent_node() { + // Graph: A -> B + let mut nodes = HashMap::new(); + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + nodes.insert("A".to_string(), a_edges); + nodes.insert("B".to_string(), HashMap::new()); + + // C doesn't exist + let path = find_shortest_path(&nodes, "A", "C"); + assert_eq!(path, None); + + // Z doesn't exist either + let path = find_shortest_path(&nodes, "Z", "A"); + assert_eq!(path, None); + } + + #[test] + fn test_graph_with_cycles() { + // Graph: A -> B -> C -> A (cycle) + // A -> D -> C (alternate path) + let mut nodes = HashMap::new(); + + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + a_edges.insert("D".to_string(), 5); + nodes.insert("A".to_string(), a_edges); + + let mut b_edges = HashMap::new(); + b_edges.insert("C".to_string(), 1); + nodes.insert("B".to_string(), b_edges); + + let mut c_edges = HashMap::new(); + c_edges.insert("A".to_string(), 1); // Cycle back to A + nodes.insert("C".to_string(), c_edges); + + let mut d_edges = HashMap::new(); + d_edges.insert("C".to_string(), 1); + nodes.insert("D".to_string(), d_edges); + + // Should find shortest path A -> B -> C + let path = find_shortest_path(&nodes, "A", "C"); + assert_eq!( + path, + Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]) + ); + } + + #[test] + fn test_build_petgraph_from_hashmap() { + // Verify graph is constructed correctly + let mut nodes = HashMap::new(); + + let mut a_edges = HashMap::new(); + a_edges.insert("B".to_string(), 1); + a_edges.insert("C".to_string(), 2); + nodes.insert("A".to_string(), a_edges); + + let mut b_edges = HashMap::new(); + b_edges.insert("C".to_string(), 1); + nodes.insert("B".to_string(), b_edges); + + nodes.insert("C".to_string(), HashMap::new()); + + let (graph, node_indices) = build_petgraph_from_hashmap(&nodes); + + // Check node count + assert_eq!(graph.node_count(), 3); + + // Check edge count: A->B, A->C, B->C = 3 edges + assert_eq!(graph.edge_count(), 3); + + // Check that all node names are in the mapping + assert!(node_indices.contains_key("A")); + assert!(node_indices.contains_key("B")); + assert!(node_indices.contains_key("C")); + + // Check that node indices are valid + let a_index = node_indices["A"]; + let b_index = node_indices["B"]; + let c_index = node_indices["C"]; + + assert_eq!(graph[a_index], "A"); + assert_eq!(graph[b_index], "B"); + assert_eq!(graph[c_index], "C"); + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/mod.rs index 4baca0ca1977f..d3cdba19e4dfc 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/mod.rs @@ -1,2 +1,3 @@ pub mod cube_bridge; +pub mod graph_utils; pub mod schemas; From fa744c43c5c29d5bfd91f2c89b5666df21dbc573 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 00:21:52 +0100 Subject: [PATCH 04/13] Implement MockJoinGraph compile method - Add compile() method that builds graph from cube definitions - Validate joins: target cube exists, primary keys for multiplied measures - Build edges HashMap with "from-to" keys - Build nodes HashMap (directed graph) - Build undirected_nodes HashMap (bidirectional connectivity) - Add 8 comprehensive unit tests --- .../cube_bridge/mock_evaluator.rs | 11 + .../cube_bridge/mock_join_graph.rs | 752 ++++++++++++++++++ 2 files changed, 763 insertions(+) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs index a731d54998bec..d8efa3ab26098 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs @@ -39,6 +39,17 @@ impl MockCubeEvaluator { } } + /// Get all measures for a cube + pub fn measures_for_cube( + &self, + cube_name: &str, + ) -> HashMap> { + self.schema + .get_cube(cube_name) + .map(|cube| cube.measures.clone()) + .unwrap_or_default() + } + /// Parse a path string like "cube.member" into ["cube", "member"] /// Returns error if the path doesn't exist in schema for the given type fn parse_and_validate_path( diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index c465b683383cb..f6eeede1c744d 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -1,3 +1,4 @@ +use crate::cube_bridge::evaluator::CubeEvaluator; use crate::cube_bridge::join_definition::JoinDefinition; use crate::cube_bridge::join_graph::JoinGraph; use crate::cube_bridge::join_hints::JoinHintItem; @@ -132,6 +133,153 @@ impl MockJoinGraph { fn edge_key(from: &str, to: &str) -> String { format!("{}-{}", from, to) } + + /// Builds join edges for a single cube + /// + /// This method extracts all joins from the cube, validates them, and creates JoinEdge instances. + /// + /// # Validation + /// - Target cube must exist + /// - Source and target cubes with multiplied measures must have primary keys + /// + /// # Returns + /// Vector of (edge_key, JoinEdge) tuples + fn build_join_edges( + &self, + cube: &crate::test_fixtures::cube_bridge::MockCubeDefinition, + evaluator: &crate::test_fixtures::cube_bridge::MockCubeEvaluator, + ) -> Result, CubeError> { + let joins = cube.joins(); + if joins.is_empty() { + return Ok(Vec::new()); + } + + let mut result = Vec::new(); + let cube_name = &cube.static_data().name; + + for (join_name, join_def) in joins { + // Validate target cube exists + if !evaluator.cube_exists(join_name.clone())? { + return Err(CubeError::user(format!("Cube {} doesn't exist", join_name))); + } + + // Check multiplied measures for source cube + let from_multiplied = self.get_multiplied_measures(cube_name, evaluator)?; + if !from_multiplied.is_empty() { + let static_data = evaluator.static_data(); + let primary_keys = static_data.primary_keys.get(cube_name); + if primary_keys.map_or(true, |pk| pk.is_empty()) { + return Err(CubeError::user(format!( + "primary key for '{}' is required when join is defined in order to make aggregates work properly", + cube_name + ))); + } + } + + // Check multiplied measures for target cube + let to_multiplied = self.get_multiplied_measures(join_name, evaluator)?; + if !to_multiplied.is_empty() { + let static_data = evaluator.static_data(); + let primary_keys = static_data.primary_keys.get(join_name); + if primary_keys.map_or(true, |pk| pk.is_empty()) { + return Err(CubeError::user(format!( + "primary key for '{}' is required when join is defined in order to make aggregates work properly", + join_name + ))); + } + } + + // Create JoinEdge + let edge = JoinEdge { + join: Rc::new(join_def.clone()), + from: cube_name.clone(), + to: join_name.clone(), + original_from: cube_name.clone(), + original_to: join_name.clone(), + }; + + let edge_key = Self::edge_key(cube_name, join_name); + result.push((edge_key, edge)); + } + + Ok(result) + } + + /// Gets measures that are "multiplied" by joins (require primary keys) + /// + /// Multiplied measure types: sum, avg, count, number + fn get_multiplied_measures( + &self, + cube_name: &str, + evaluator: &crate::test_fixtures::cube_bridge::MockCubeEvaluator, + ) -> Result, CubeError> { + let measures = evaluator.measures_for_cube(cube_name); + let multiplied_types = ["sum", "avg", "count", "number"]; + + let mut result = Vec::new(); + for (measure_name, measure) in measures { + let measure_type = &measure.static_data().measure_type; + if multiplied_types.contains(&measure_type.as_str()) { + result.push(measure_name); + } + } + + Ok(result) + } + + /// Compiles the join graph from cube definitions + /// + /// This method processes all cubes and their join definitions to build the internal + /// graph structure needed for join path finding. It validates that: + /// - All referenced cubes exist + /// - Cubes with multiplied measures have primary keys defined + /// + /// # Arguments + /// * `cubes` - Slice of cube definitions to compile + /// * `evaluator` - Evaluator for validation and lookups + /// + /// # Returns + /// * `Ok(())` if compilation succeeds + /// * `Err(CubeError)` if validation fails + pub fn compile( + &mut self, + cubes: &[Rc], + evaluator: &crate::test_fixtures::cube_bridge::MockCubeEvaluator, + ) -> Result<(), CubeError> { + // Clear existing state + self.edges.clear(); + self.nodes.clear(); + self.undirected_nodes.clear(); + self.cached_connected_components = None; + + // Build edges from all cubes + for cube in cubes { + let cube_edges = self.build_join_edges(cube, evaluator)?; + for (key, edge) in cube_edges { + self.edges.insert(key, edge); + } + } + + // Build nodes HashMap (directed graph) + // Group edges by 'from' field and create HashMap of destinations + for (_, edge) in &self.edges { + self.nodes + .entry(edge.from.clone()) + .or_insert_with(HashMap::new) + .insert(edge.to.clone(), 1); + } + + // Build undirected_nodes HashMap + // For each edge (from -> to), also add (to -> from) for bidirectional connectivity + for (_, edge) in &self.edges { + self.undirected_nodes + .entry(edge.to.clone()) + .or_insert_with(HashMap::new) + .insert(edge.from.clone(), 1); + } + + Ok(()) + } } impl Default for MockJoinGraph { @@ -156,6 +304,10 @@ impl JoinGraph for MockJoinGraph { #[cfg(test)] mod tests { use super::*; + use crate::cube_bridge::evaluator::CubeEvaluator; + use crate::test_fixtures::cube_bridge::{ + MockDimensionDefinition, MockMeasureDefinition, MockSchemaBuilder, + }; #[test] fn test_mock_join_graph_new() { @@ -221,4 +373,604 @@ mod tests { assert!(cloned.nodes.is_empty()); assert!(cloned.undirected_nodes.is_empty()); } + + #[test] + fn test_compile_simple_graph() { + // Create schema: orders -> users + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Verify edges contains "orders-users" + assert!(graph.edges.contains_key("orders-users")); + assert_eq!(graph.edges.len(), 1); + + // Verify nodes: {"orders": {"users": 1}} + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("orders")); + let orders_destinations = graph.nodes.get("orders").unwrap(); + assert_eq!(orders_destinations.get("users"), Some(&1)); + + // Verify undirected_nodes: {"users": {"orders": 1}} + assert_eq!(graph.undirected_nodes.len(), 1); + assert!(graph.undirected_nodes.contains_key("users")); + let users_connections = graph.undirected_nodes.get("users").unwrap(); + assert_eq!(users_connections.get("orders"), Some(&1)); + } + + #[test] + fn test_compile_multiple_joins() { + // Create schema: orders -> users, orders -> products, products -> categories + let schema = MockSchemaBuilder::new() + .add_cube("categories") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("products") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "categories", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.category_id = {categories.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .add_join( + "products", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.product_id = {products.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("categories".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("products".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Verify all edges present + assert_eq!(graph.edges.len(), 3); + assert!(graph.edges.contains_key("orders-users")); + assert!(graph.edges.contains_key("orders-products")); + assert!(graph.edges.contains_key("products-categories")); + + // Verify nodes correctly structured + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("orders")); + assert!(graph.nodes.contains_key("products")); + + let orders_dests = graph.nodes.get("orders").unwrap(); + assert_eq!(orders_dests.len(), 2); + assert_eq!(orders_dests.get("users"), Some(&1)); + assert_eq!(orders_dests.get("products"), Some(&1)); + + let products_dests = graph.nodes.get("products").unwrap(); + assert_eq!(products_dests.len(), 1); + assert_eq!(products_dests.get("categories"), Some(&1)); + } + + #[test] + fn test_compile_bidirectional() { + // Create schema: A -> B, B -> A + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("one_to_many".to_string()) + .sql("{CUBE}.id = {B.a_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "A", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.a_id = {A.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Verify both directions in edges + assert_eq!(graph.edges.len(), 2); + assert!(graph.edges.contains_key("A-B")); + assert!(graph.edges.contains_key("B-A")); + + // Verify undirected_nodes handles properly + assert_eq!(graph.undirected_nodes.len(), 2); + assert!(graph.undirected_nodes.contains_key("A")); + assert!(graph.undirected_nodes.contains_key("B")); + } + + #[test] + fn test_compile_nonexistent_cube() { + // Create cube A with join to nonexistent B + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + )]; + + let mut graph = MockJoinGraph::new(); + let result = graph.compile(&cubes, &evaluator); + + // Compile should return error + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("Cube B doesn't exist")); + } + + #[test] + fn test_compile_missing_primary_key() { + // Create cube A with multiplied measure (count) and no primary key + let schema = MockSchemaBuilder::new() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + let result = graph.compile(&cubes, &evaluator); + + // Compile should return error + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("primary key for 'A' is required")); + } + + #[test] + fn test_compile_with_primary_key() { + // Create cube A with multiplied measure and primary key + let schema = MockSchemaBuilder::new() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + let result = graph.compile(&cubes, &evaluator); + + // Compile should succeed + assert!(result.is_ok()); + assert_eq!(graph.edges.len(), 1); + assert!(graph.edges.contains_key("A-B")); + } + + #[test] + fn test_recompile_clears_state() { + // Compile with schema A -> B + let schema1 = MockSchemaBuilder::new() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator1 = schema1.create_evaluator(); + let cubes1: Vec> = vec![ + Rc::new( + evaluator1 + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator1 + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes1, &evaluator1).unwrap(); + assert_eq!(graph.edges.len(), 1); + assert!(graph.edges.contains_key("A-B")); + + // Recompile with schema C -> D + let schema2 = MockSchemaBuilder::new() + .add_cube("D") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "D", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.d_id = {D.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator2 = schema2.create_evaluator(); + let cubes2: Vec> = vec![ + Rc::new( + evaluator2 + .cube_from_path("D".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator2 + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + graph.compile(&cubes2, &evaluator2).unwrap(); + + // Verify old edges gone + assert!(!graph.edges.contains_key("A-B")); + + // Verify only new edges present + assert_eq!(graph.edges.len(), 1); + assert!(graph.edges.contains_key("C-D")); + } + + #[test] + fn test_compile_empty() { + // Compile with empty cube list + let schema = MockSchemaBuilder::new().build(); + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Verify all HashMaps empty + assert!(graph.edges.is_empty()); + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); + } } From e1240b00037a69d367198d12f3648e05e2efbb21 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 00:45:35 +0100 Subject: [PATCH 05/13] Implement core join building logic --- .../cube_bridge/mock_join_definition.rs | 2 +- .../cube_bridge/mock_join_graph.rs | 1146 ++++++++++++++++- .../cube_bridge/mock_join_item.rs | 2 +- 3 files changed, 1139 insertions(+), 11 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs index 973ced3782e1f..0c70ba4cb650d 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs @@ -9,7 +9,7 @@ use std::rc::Rc; use typed_builder::TypedBuilder; /// Mock implementation of JoinDefinition for testing -#[derive(TypedBuilder)] +#[derive(Debug, TypedBuilder)] pub struct MockJoinDefinition { // Fields from JoinDefinitionStatic root: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index f6eeede1c744d..6c262478afe83 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -5,6 +5,7 @@ use crate::cube_bridge::join_hints::JoinHintItem; use crate::test_fixtures::cube_bridge::{MockJoinDefinition, MockJoinItemDefinition}; use cubenativeutils::CubeError; use std::any::Any; +use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -86,7 +87,8 @@ pub struct MockJoinGraph { /// Cache of built join trees: serialized cubes -> JoinDefinition /// Stores previously computed join paths for reuse - built_joins: HashMap>, + /// Uses RefCell for interior mutability (allows caching through &self) + built_joins: RefCell>>, /// Cache for connected components /// Stores the connected component ID for each cube @@ -109,7 +111,7 @@ impl MockJoinGraph { nodes: HashMap::new(), undirected_nodes: HashMap::new(), edges: HashMap::new(), - built_joins: HashMap::new(), + built_joins: RefCell::new(HashMap::new()), cached_connected_components: None, } } @@ -227,6 +229,348 @@ impl MockJoinGraph { Ok(result) } + /// Extracts the cube name from a JoinHintItem + /// + /// For Single variants, returns the cube name directly. + /// For Vector variants, returns the last element (the destination). + /// + /// # Arguments + /// * `cube_path` - The JoinHintItem to extract from + /// + /// # Returns + /// The cube name as a String + /// + /// # Example + /// ``` + /// use cubesqlplanner::cube_bridge::join_hints::JoinHintItem; + /// # use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; + /// # let graph = MockJoinGraph::new(); + /// + /// let single = JoinHintItem::Single("users".to_string()); + /// assert_eq!(graph.cube_from_path(&single), "users"); + /// + /// let vector = JoinHintItem::Vector(vec!["orders".to_string(), "users".to_string()]); + /// assert_eq!(graph.cube_from_path(&vector), "users"); + /// ``` + fn cube_from_path(&self, cube_path: &JoinHintItem) -> String { + match cube_path { + JoinHintItem::Single(name) => name.clone(), + JoinHintItem::Vector(path) => path + .last() + .expect("Vector path should not be empty") + .clone(), + } + } + + /// Converts a path of cube names to a list of JoinEdges + /// + /// For a path [A, B, C], this looks up edges "A-B" and "B-C" in the edges HashMap. + /// + /// # Arguments + /// * `path` - Slice of cube names representing the path + /// + /// # Returns + /// Vector of JoinEdge instances corresponding to consecutive pairs in the path + /// + /// # Example + /// ```ignore + /// // For path ["orders", "users", "countries"] + /// // Returns edges for "orders-users" and "users-countries" + /// let path = vec!["orders".to_string(), "users".to_string(), "countries".to_string()]; + /// let joins = graph.joins_by_path(&path); + /// ``` + fn joins_by_path(&self, path: &[String]) -> Vec { + let mut result = Vec::new(); + for i in 0..path.len().saturating_sub(1) { + let key = Self::edge_key(&path[i], &path[i + 1]); + if let Some(edge) = self.edges.get(&key) { + result.push(edge.clone()); + } + } + result + } + + /// Builds a join tree with a specific root cube + /// + /// This method tries to build a join tree starting from the specified root, + /// connecting to all cubes in cubes_to_join. It uses Dijkstra's algorithm + /// to find the shortest paths. + /// + /// # Arguments + /// * `root` - The root cube (can be Single or Vector) + /// * `cubes_to_join` - Other cubes to connect to the root + /// + /// # Returns + /// * `Some((root_name, joins))` - If a valid join tree can be built + /// * `None` - If no path exists to connect all cubes + /// + /// # Algorithm + /// 1. Extract root name (if Vector, first element becomes root, rest go to cubes_to_join) + /// 2. Track joined nodes to avoid duplicates + /// 3. For each cube to join: + /// - Find shortest path from previous node + /// - Convert path to JoinEdge list + /// - Mark nodes as joined + /// 4. Collect and deduplicate all joins + fn build_join_tree_for_root( + &self, + root: &JoinHintItem, + cubes_to_join: &[JoinHintItem], + ) -> Option<(String, Vec)> { + use crate::test_fixtures::graph_utils::find_shortest_path; + use std::collections::HashSet; + + // Extract root and additional cubes to join + let (root_name, additional_cubes) = match root { + JoinHintItem::Single(name) => (name.clone(), Vec::new()), + JoinHintItem::Vector(path) => { + if path.is_empty() { + return None; + } + let root_name = path[0].clone(); + let additional = if path.len() > 1 { + vec![JoinHintItem::Vector(path[1..].to_vec())] + } else { + Vec::new() + }; + (root_name, additional) + } + }; + + // Combine additional cubes with cubes_to_join + let mut all_cubes_to_join = additional_cubes; + all_cubes_to_join.extend_from_slice(cubes_to_join); + + // Track which nodes have been joined + let mut nodes_joined: HashSet = HashSet::new(); + + // Collect all joins with their indices + let mut all_joins: Vec<(usize, JoinEdge)> = Vec::new(); + let mut next_index = 0; + + // Process each cube to join + for join_hint in &all_cubes_to_join { + // Convert to Vector if Single + let path_elements = match join_hint { + JoinHintItem::Single(name) => vec![name.clone()], + JoinHintItem::Vector(path) => path.clone(), + }; + + // Find path from previous node to each target + let mut prev_node = root_name.clone(); + + for to_join in &path_elements { + // Skip if already joined or same as previous + if to_join == &prev_node { + continue; + } + + if nodes_joined.contains(to_join) { + prev_node = to_join.clone(); + continue; + } + + // Find shortest path + let path = find_shortest_path(&self.nodes, &prev_node, to_join); + if path.is_none() { + return None; // Can't find path + } + + let path = path.unwrap(); + + // Convert path to joins + let found_joins = self.joins_by_path(&path); + + // Add joins with indices + for join in found_joins { + all_joins.push((next_index, join)); + next_index += 1; + } + + // Mark as joined + nodes_joined.insert(to_join.clone()); + prev_node = to_join.clone(); + } + } + + // Sort by index and remove duplicates + all_joins.sort_by_key(|(idx, _)| *idx); + + // Remove duplicates by edge key + let mut seen_keys: HashSet = HashSet::new(); + let mut unique_joins: Vec = Vec::new(); + + for (_, join) in all_joins { + let key = Self::edge_key(&join.from, &join.to); + if !seen_keys.contains(&key) { + seen_keys.insert(key); + unique_joins.push(join); + } + } + + Some((root_name, unique_joins)) + } + + /// Builds a join definition from a list of cubes to join + /// + /// This is the main entry point for finding optimal join paths between cubes. + /// It tries each cube as a potential root and selects the shortest join tree. + /// + /// # Arguments + /// * `cubes_to_join` - Vector of JoinHintItem specifying which cubes to join + /// + /// # Returns + /// * `Ok(Rc)` - The optimal join definition with multiplication factors + /// * `Err(CubeError)` - If no join path exists or input is empty + /// + /// # Caching + /// Results are cached based on the serialized cubes_to_join. + /// Subsequent calls with the same cubes return the cached result. + /// + /// # Algorithm + /// 1. Check cache for existing result + /// 2. Try each cube as root, find shortest tree + /// 3. Calculate multiplication factors for each cube + /// 4. Create MockJoinDefinition with results + /// 5. Cache and return + /// + /// # Example + /// ```ignore + /// let cubes = vec![ + /// JoinHintItem::Single("orders".to_string()), + /// JoinHintItem::Single("users".to_string()), + /// ]; + /// let join_def = graph.build_join(cubes)?; + /// ``` + pub fn build_join( + &self, + cubes_to_join: Vec, + ) -> Result, CubeError> { + // Handle empty input + if cubes_to_join.is_empty() { + return Err(CubeError::user( + "Cannot build join with empty cube list".to_string(), + )); + } + + // Check cache + let cache_key = serde_json::to_string(&cubes_to_join).map_err(|e| { + CubeError::internal(format!("Failed to serialize cubes_to_join: {}", e)) + })?; + + { + let cache = self.built_joins.borrow(); + if let Some(cached) = cache.get(&cache_key) { + return Ok(cached.clone()); + } + } + + // Try each cube as root + let mut join_trees: Vec<(String, Vec)> = Vec::new(); + + for i in 0..cubes_to_join.len() { + let root = &cubes_to_join[i]; + let mut other_cubes = Vec::new(); + other_cubes.extend_from_slice(&cubes_to_join[0..i]); + other_cubes.extend_from_slice(&cubes_to_join[i + 1..]); + + if let Some(tree) = self.build_join_tree_for_root(root, &other_cubes) { + join_trees.push(tree); + } + } + + // Sort by number of joins (shortest first) + join_trees.sort_by_key(|(_, joins)| joins.len()); + + // Take the shortest tree + let (root_name, joins) = join_trees.first().ok_or_else(|| { + let cube_names: Vec = cubes_to_join + .iter() + .map(|hint| match hint { + JoinHintItem::Single(name) => format!("'{}'", name), + JoinHintItem::Vector(path) => format!("'{}'", path.join(".")), + }) + .collect(); + CubeError::user(format!( + "Can't find join path to join {}", + cube_names.join(", ") + )) + })?; + + // Calculate multiplication factors + let mut multiplication_factor: HashMap = HashMap::new(); + for cube_hint in &cubes_to_join { + let cube_name = self.cube_from_path(cube_hint); + let factor = self.find_multiplication_factor_for(&cube_name, joins); + multiplication_factor.insert(cube_name, factor); + } + + // Convert JoinEdges to MockJoinItems + let join_items: Vec> = joins + .iter() + .map(|edge| self.join_edge_to_mock_join_item(edge)) + .collect(); + + // Create MockJoinDefinition + let join_def = Rc::new( + MockJoinDefinition::builder() + .root(root_name.clone()) + .joins(join_items) + .multiplication_factor(multiplication_factor) + .build(), + ); + + // Cache and return + self.built_joins + .borrow_mut() + .insert(cache_key, join_def.clone()); + + Ok(join_def) + } + + /// Converts a JoinEdge to a MockJoinItem + /// + /// Helper method to convert internal JoinEdge representation to the MockJoinItem + /// type used in MockJoinDefinition. + /// + /// # Arguments + /// * `edge` - The JoinEdge to convert + /// + /// # Returns + /// Rc with the same from/to/original_from/original_to and join definition + fn join_edge_to_mock_join_item( + &self, + edge: &JoinEdge, + ) -> Rc { + use crate::test_fixtures::cube_bridge::MockJoinItem; + + Rc::new( + MockJoinItem::builder() + .from(edge.from.clone()) + .to(edge.to.clone()) + .original_from(edge.original_from.clone()) + .original_to(edge.original_to.clone()) + .join(edge.join.clone()) + .build(), + ) + } + + /// Checks if a cube has a multiplication factor in the join tree + /// + /// This is a stub implementation that will be completed in Step 6. + /// For now, it returns false for all cubes. + /// + /// # Arguments + /// * `_cube` - The cube name to check + /// * `_joins` - The join edges in the tree + /// + /// # Returns + /// * `false` - stub always returns false + fn find_multiplication_factor_for(&self, _cube: &str, _joins: &[JoinEdge]) -> bool { + // TODO: Implement in Step 6 + false + } + /// Compiles the join graph from cube definitions /// /// This method processes all cubes and their join definitions to build the internal @@ -252,6 +596,12 @@ impl MockJoinGraph { self.undirected_nodes.clear(); self.cached_connected_components = None; + // First, ensure all cubes exist in nodes HashMap (even if they have no joins) + for cube in cubes { + let cube_name = cube.static_data().name.clone(); + self.nodes.entry(cube_name).or_insert_with(HashMap::new); + } + // Build edges from all cubes for cube in cubes { let cube_edges = self.build_join_edges(cube, evaluator)?; @@ -295,9 +645,11 @@ impl JoinGraph for MockJoinGraph { fn build_join( &self, - _cubes_to_join: Vec, + cubes_to_join: Vec, ) -> Result, CubeError> { - todo!("build_join not implemented in MockJoinGraph") + // Call our implementation and cast to trait object + let result = self.build_join(cubes_to_join)?; + Ok(result as Rc) } } @@ -317,7 +669,7 @@ mod tests { assert!(graph.nodes.is_empty()); assert!(graph.undirected_nodes.is_empty()); assert!(graph.edges.is_empty()); - assert!(graph.built_joins.is_empty()); + assert!(graph.built_joins.borrow().is_empty()); assert!(graph.cached_connected_components.is_none()); } @@ -435,9 +787,10 @@ mod tests { assert!(graph.edges.contains_key("orders-users")); assert_eq!(graph.edges.len(), 1); - // Verify nodes: {"orders": {"users": 1}} - assert_eq!(graph.nodes.len(), 1); + // Verify nodes: both cubes present, "orders" has edge to "users" + assert_eq!(graph.nodes.len(), 2); assert!(graph.nodes.contains_key("orders")); + assert!(graph.nodes.contains_key("users")); let orders_destinations = graph.nodes.get("orders").unwrap(); assert_eq!(orders_destinations.get("users"), Some(&1)); @@ -563,10 +916,12 @@ mod tests { assert!(graph.edges.contains_key("orders-products")); assert!(graph.edges.contains_key("products-categories")); - // Verify nodes correctly structured - assert_eq!(graph.nodes.len(), 2); + // Verify nodes correctly structured - all 4 cubes should be present + assert_eq!(graph.nodes.len(), 4); assert!(graph.nodes.contains_key("orders")); assert!(graph.nodes.contains_key("products")); + assert!(graph.nodes.contains_key("users")); + assert!(graph.nodes.contains_key("categories")); let orders_dests = graph.nodes.get("orders").unwrap(); assert_eq!(orders_dests.len(), 2); @@ -972,5 +1327,778 @@ mod tests { assert!(graph.edges.is_empty()); assert!(graph.nodes.is_empty()); assert!(graph.undirected_nodes.is_empty()); + assert!(graph.built_joins.borrow().is_empty()); + } + + // Tests for build_join functionality + + #[test] + fn test_build_join_simple() { + // Schema: A -> B + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A -> B + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("B".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=A, joins=[A->B], no multiplication + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 1); + + let join_static = joins[0].static_data(); + assert_eq!(join_static.from, "A"); + assert_eq!(join_static.to, "B"); + + // Check multiplication factors + let mult_factors = result.static_data().multiplication_factor.clone(); + assert_eq!(mult_factors.get("A"), Some(&false)); + assert_eq!(mult_factors.get("B"), Some(&false)); + } + + #[test] + fn test_build_join_chain() { + // Schema: A -> B -> C + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A -> B -> C + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("B".to_string()), + JoinHintItem::Single("C".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=A, joins=[A->B, B->C] + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 2); + + assert_eq!(joins[0].static_data().from, "A"); + assert_eq!(joins[0].static_data().to, "B"); + assert_eq!(joins[1].static_data().from, "B"); + assert_eq!(joins[1].static_data().to, "C"); + } + + #[test] + fn test_build_join_shortest_path() { + // Schema: A -> B -> C (2 hops) + // A -> C (1 hop - shortest) + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A, C + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("C".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: use direct path A->C (not A->B->C) + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 1); + + assert_eq!(joins[0].static_data().from, "A"); + assert_eq!(joins[0].static_data().to, "C"); + } + + #[test] + fn test_build_join_star_pattern() { + // Schema: A -> B, A -> C, A -> D + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .add_join( + "D", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.d_id = {D.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("D") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("D".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A, B, C, D + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("B".to_string()), + JoinHintItem::Single("C".to_string()), + JoinHintItem::Single("D".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=A, joins to all others + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 3); + + // All joins should be from A + assert_eq!(joins[0].static_data().from, "A"); + assert_eq!(joins[1].static_data().from, "A"); + assert_eq!(joins[2].static_data().from, "A"); + } + + #[test] + fn test_build_join_disconnected() { + // Schema: A -> B, C -> D (no connection) + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "D", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.d_id = {D.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("D") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("D".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A, D (disconnected) + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("D".to_string()), + ]; + let result = graph.build_join(cubes_to_join); + + // Expected: error "Can't find join path" + assert!(result.is_err()); + let err_msg = result.unwrap_err().message; + assert!(err_msg.contains("Can't find join path")); + assert!(err_msg.contains("'A'")); + assert!(err_msg.contains("'D'")); + } + + #[test] + fn test_build_join_empty() { + let schema = MockSchemaBuilder::new().build(); + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join with empty list + let cubes_to_join = vec![]; + let result = graph.build_join(cubes_to_join); + + // Expected: error + assert!(result.is_err()); + let err_msg = result.unwrap_err().message; + assert!(err_msg.contains("empty")); + } + + #[test] + fn test_build_join_single_cube() { + // Schema: A + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + )]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join with single cube + let cubes_to_join = vec![JoinHintItem::Single("A".to_string())]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=A, no joins + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 0); + } + + #[test] + fn test_build_join_caching() { + // Schema: A -> B + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join twice + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("B".to_string()), + ]; + let result1 = graph.build_join(cubes_to_join.clone()).unwrap(); + let result2 = graph.build_join(cubes_to_join).unwrap(); + + // Verify same Rc returned (pointer equality) + assert!(Rc::ptr_eq(&result1, &result2)); + } + + #[test] + fn test_build_join_with_vector_hint() { + // Schema: A -> B -> C + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join with Vector hint: [A, B] becomes root=A, join to B, then join to C + let cubes_to_join = vec![ + JoinHintItem::Vector(vec!["A".to_string(), "B".to_string()]), + JoinHintItem::Single("C".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=A, joins=[A->B, B->C] + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 2); + + assert_eq!(joins[0].static_data().from, "A"); + assert_eq!(joins[0].static_data().to, "B"); + assert_eq!(joins[1].static_data().from, "B"); + assert_eq!(joins[1].static_data().to, "C"); } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item.rs index 29c412c602d70..5ee6e85c78a02 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_item.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use typed_builder::TypedBuilder; /// Mock implementation of JoinItem for testing -#[derive(TypedBuilder)] +#[derive(Debug, TypedBuilder)] pub struct MockJoinItem { // Fields from JoinItemStatic from: String, From 03670aa82fbaf11f8ba2b65834a0e3cbcc1ed806 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 00:59:29 +0100 Subject: [PATCH 06/13] Implement multiplication factor calculation --- .../cube_bridge/mock_join_graph.rs | 480 +++++++++++++++++- 1 file changed, 471 insertions(+), 9 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index 6c262478afe83..3a133ad5aeb7e 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -555,20 +555,102 @@ impl MockJoinGraph { ) } - /// Checks if a cube has a multiplication factor in the join tree + /// Checks if a specific join causes row multiplication for a cube /// - /// This is a stub implementation that will be completed in Step 6. - /// For now, it returns false for all cubes. + /// # Multiplication Rules + /// - If join.from == cube && relationship == "hasMany": multiplies + /// - If join.to == cube && relationship == "belongsTo": multiplies + /// - Otherwise: no multiplication /// /// # Arguments - /// * `_cube` - The cube name to check - /// * `_joins` - The join edges in the tree + /// * `cube` - The cube name to check + /// * `join` - The join edge to examine /// /// # Returns - /// * `false` - stub always returns false - fn find_multiplication_factor_for(&self, _cube: &str, _joins: &[JoinEdge]) -> bool { - // TODO: Implement in Step 6 - false + /// * `true` if this join multiplies rows for the cube + /// * `false` otherwise + fn check_if_cube_multiplied(&self, cube: &str, join: &JoinEdge) -> bool { + let relationship = &join.join.static_data().relationship; + + (join.from == cube && relationship == "hasMany") + || (join.to == cube && relationship == "belongsTo") + } + + /// Determines if a cube has a multiplication factor in the join tree + /// + /// This method walks the join tree recursively to determine if joining + /// this cube causes row multiplication due to hasMany or belongsTo relationships. + /// + /// # Algorithm + /// 1. Start from the target cube + /// 2. Find all adjacent joins in the tree + /// 3. Check if any immediate join causes multiplication + /// 4. If not, recursively check adjacent cubes + /// 5. Use visited set to prevent infinite loops + /// + /// # Arguments + /// * `cube` - The cube name to check + /// * `joins` - The join edges in the tree + /// + /// # Returns + /// * `true` if this cube causes row multiplication + /// * `false` otherwise + /// + /// # Example + /// ```ignore + /// // users hasMany orders + /// let joins = vec![join_users_to_orders]; + /// assert!(graph.find_multiplication_factor_for("users", &joins)); + /// assert!(!graph.find_multiplication_factor_for("orders", &joins)); + /// ``` + fn find_multiplication_factor_for(&self, cube: &str, joins: &[JoinEdge]) -> bool { + use std::collections::HashSet; + + let mut visited: HashSet = HashSet::new(); + + fn find_if_multiplied_recursive( + graph: &MockJoinGraph, + current_cube: &str, + joins: &[JoinEdge], + visited: &mut HashSet, + ) -> bool { + // Check if already visited (prevent cycles) + if visited.contains(current_cube) { + return false; + } + visited.insert(current_cube.to_string()); + + // Helper to get next node in edge + let next_node = |join: &JoinEdge| -> String { + if join.from == current_cube { + join.to.clone() + } else { + join.from.clone() + } + }; + + // Find all joins adjacent to current cube + let next_joins: Vec<&JoinEdge> = joins + .iter() + .filter(|j| j.from == current_cube || j.to == current_cube) + .collect(); + + // Check if any immediate join multiplies AND leads to unvisited node + if next_joins.iter().any(|next_join| { + let next = next_node(next_join); + graph.check_if_cube_multiplied(current_cube, next_join) && !visited.contains(&next) + }) { + return true; + } + + // Recursively check adjacent cubes + next_joins.iter().any(|next_join| { + let next = next_node(next_join); + find_if_multiplied_recursive(graph, &next, joins, visited) + }) + } + + find_if_multiplied_recursive(self, cube, joins, &mut visited) } /// Compiles the join graph from cube definitions @@ -2000,6 +2082,386 @@ mod tests { assert!(Rc::ptr_eq(&result1, &result2)); } + #[test] + fn test_multiplication_factor_has_many() { + // users hasMany orders + // users should multiply, orders should not + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); + } + + #[test] + fn test_multiplication_factor_belongs_to() { + // orders belongsTo users + // users should multiply, orders should not + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); + } + + #[test] + fn test_multiplication_factor_transitive() { + // users hasMany orders, orders hasMany items + // users multiplies (direct hasMany) + // orders multiplies (has hasMany to items) + // items does not multiply + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {items.order_id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "items".to_string(), + original_from: "orders".to_string(), + original_to: "items".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(graph.find_multiplication_factor_for("orders", &joins)); + assert!(!graph.find_multiplication_factor_for("items", &joins)); + } + + #[test] + fn test_multiplication_factor_many_to_one() { + // orders many_to_one users (neither multiplies) + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }]; + + assert!(!graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); + } + + #[test] + fn test_multiplication_factor_star_pattern() { + // users hasMany orders, users hasMany sessions + // In this graph topology: + // - users multiplies (has hasMany to unvisited nodes) + // - orders multiplies (connected to users which has hasMany to sessions) + // - sessions multiplies (connected to users which has hasMany to orders) + // This is because the algorithm checks for multiplication in the connected component + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {sessions.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "sessions".to_string(), + original_from: "users".to_string(), + original_to: "sessions".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + // orders and sessions both return true because users (connected node) has hasMany + assert!(graph.find_multiplication_factor_for("orders", &joins)); + assert!(graph.find_multiplication_factor_for("sessions", &joins)); + } + + #[test] + fn test_multiplication_factor_cycle() { + // A hasMany B, B hasMany A (cycle) + // Both should multiply + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {B.a_id}".to_string()) + .build(), + ), + from: "A".to_string(), + to: "B".to_string(), + original_from: "A".to_string(), + original_to: "B".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {A.b_id}".to_string()) + .build(), + ), + from: "B".to_string(), + to: "A".to_string(), + original_from: "B".to_string(), + original_to: "A".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("A", &joins)); + assert!(graph.find_multiplication_factor_for("B", &joins)); + } + + #[test] + fn test_multiplication_factor_empty_joins() { + // No joins, no multiplication + let graph = MockJoinGraph::new(); + let joins = vec![]; + + assert!(!graph.find_multiplication_factor_for("users", &joins)); + } + + #[test] + fn test_multiplication_factor_disconnected() { + // orders hasMany items (users disconnected) + // users not in join tree, should not multiply + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {items.order_id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "items".to_string(), + original_from: "orders".to_string(), + original_to: "items".to_string(), + }]; + + assert!(!graph.find_multiplication_factor_for("users", &joins)); + } + + #[test] + fn test_build_join_with_multiplication_factors() { + // Schema: users hasMany orders, orders many_to_one products + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "orders", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "products", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.product_id = {products.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("products") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("products".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: users -> orders -> products + let cubes_to_join = vec![ + JoinHintItem::Single("users".to_string()), + JoinHintItem::Single("orders".to_string()), + JoinHintItem::Single("products".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Check multiplication factors + let mult_factors = result.static_data().multiplication_factor.clone(); + + // users hasMany orders -> users multiplies + assert_eq!(mult_factors.get("users"), Some(&true)); + + // orders is in the middle, does not have its own hasMany, does not multiply + assert_eq!(mult_factors.get("orders"), Some(&false)); + + // products is leaf with many_to_one, does not multiply + assert_eq!(mult_factors.get("products"), Some(&false)); + } + + #[test] + fn test_check_if_cube_multiplied() { + let graph = MockJoinGraph::new(); + + // hasMany: from side multiplies + let join_has_many = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }; + + assert!(graph.check_if_cube_multiplied("users", &join_has_many)); + assert!(!graph.check_if_cube_multiplied("orders", &join_has_many)); + + // belongsTo: to side multiplies + let join_belongs_to = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }; + + assert!(graph.check_if_cube_multiplied("users", &join_belongs_to)); + assert!(!graph.check_if_cube_multiplied("orders", &join_belongs_to)); + + // many_to_one: no multiplication + let join_many_to_one = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }; + + assert!(!graph.check_if_cube_multiplied("users", &join_many_to_one)); + assert!(!graph.check_if_cube_multiplied("orders", &join_many_to_one)); + } + #[test] fn test_build_join_with_vector_hint() { // Schema: A -> B -> C From e76775940b4a0c1e7be2aae0d796072f915a4aa9 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 01:13:07 +0100 Subject: [PATCH 07/13] Implement connected components --- .../cube_bridge/mock_join_graph.rs | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index 3a133ad5aeb7e..1bc69d5a0a6e4 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -712,6 +712,106 @@ impl MockJoinGraph { Ok(()) } + + /// Recursively marks all cubes in a connected component + /// + /// This method performs a depth-first search starting from the given node, + /// marking all reachable nodes with the same component ID. It uses the + /// undirected_nodes graph to traverse in both directions. + /// + /// # Algorithm + /// 1. Check if node already has a component ID (base case) + /// 2. Assign component ID to current node + /// 3. Find all connected nodes in undirected_nodes graph + /// 4. Recursively process each connected node + /// + /// # Arguments + /// * `component_id` - The ID to assign to this component + /// * `node` - The current cube name being processed + /// * `components` - Mutable map of cube -> component_id + /// + /// # Example + /// ```ignore + /// let mut components = HashMap::new(); + /// graph.find_connected_component(1, "users", &mut components); + /// // All cubes reachable from "users" now have component_id = 1 + /// ``` + fn find_connected_component( + &self, + component_id: u32, + node: &str, + components: &mut HashMap, + ) { + // Base case: already visited + if components.contains_key(node) { + return; + } + + // Mark this node with component ID + components.insert(node.to_string(), component_id); + + // Get connected nodes from undirected graph (backward edges: to -> from) + if let Some(connected_nodes) = self.undirected_nodes.get(node) { + for connected_node in connected_nodes.keys() { + self.find_connected_component(component_id, connected_node, components); + } + } + + // Also traverse forward edges (from -> to) + if let Some(connected_nodes) = self.nodes.get(node) { + for connected_node in connected_nodes.keys() { + self.find_connected_component(component_id, connected_node, components); + } + } + } + + /// Returns connected components of the join graph + /// + /// This method identifies which cubes are connected through join relationships. + /// Cubes in the same component can be joined together. Cubes in different + /// components cannot be joined and would result in a query error. + /// + /// Component IDs start at 1 and increment for each disconnected subgraph. + /// Isolated cubes (with no joins) each get their own unique component ID. + /// + /// # Returns + /// HashMap mapping cube name to component ID (1-based) + /// + /// # Example + /// ```ignore + /// // Graph: users <-> orders, products (isolated) + /// let components = graph.connected_components(); + /// assert_eq!(components.get("users"), components.get("orders")); // Same component + /// assert_ne!(components.get("users"), components.get("products")); // Different + /// ``` + /// + /// # Caching + /// Results are cached and reused on subsequent calls until `compile()` is called. + pub fn connected_components(&mut self) -> HashMap { + // Return cached result if available + if let Some(cached) = &self.cached_connected_components { + return cached.clone(); + } + + let mut component_id: u32 = 1; + let mut components: HashMap = HashMap::new(); + + // Process all nodes (includes isolated cubes) + let node_names: Vec = self.nodes.keys().cloned().collect(); + + for node in node_names { + // Only process if not already assigned to a component + if !components.contains_key(&node) { + self.find_connected_component(component_id, &node, &mut components); + component_id += 1; + } + } + + // Cache results + self.cached_connected_components = Some(components.clone()); + + components + } } impl Default for MockJoinGraph { @@ -2563,4 +2663,668 @@ mod tests { assert_eq!(joins[1].static_data().from, "B"); assert_eq!(joins[1].static_data().to, "C"); } + + #[test] + fn test_connected_components_simple() { + // Graph: users -> orders (both in same component) + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // Both cubes should be in same component + assert_eq!(components.len(), 2); + let users_comp = components.get("users").unwrap(); + let orders_comp = components.get("orders").unwrap(); + assert_eq!(users_comp, orders_comp); + } + + #[test] + fn test_connected_components_disconnected() { + // Graph: users -> orders, products (isolated) + // Two components: {users, orders}, {products} + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("products") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("products".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // All three cubes should have component IDs + assert_eq!(components.len(), 3); + + // users and orders in same component + let users_comp = components.get("users").unwrap(); + let orders_comp = components.get("orders").unwrap(); + assert_eq!(users_comp, orders_comp); + + // products in different component + let products_comp = components.get("products").unwrap(); + assert_ne!(users_comp, products_comp); + } + + #[test] + fn test_connected_components_all_isolated() { + // Graph: A, B, C (no joins) + // Three components: {A}, {B}, {C} + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // All three cubes in different components + assert_eq!(components.len(), 3); + let a_comp = components.get("A").unwrap(); + let b_comp = components.get("B").unwrap(); + let c_comp = components.get("C").unwrap(); + assert_ne!(a_comp, b_comp); + assert_ne!(b_comp, c_comp); + assert_ne!(a_comp, c_comp); + } + + #[test] + fn test_connected_components_large_connected() { + // Chain: A -> B -> C -> D (all in same component) + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "D", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.d_id = {D.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("D") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("D".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // All four cubes in same component + assert_eq!(components.len(), 4); + let a_comp = components.get("A").unwrap(); + let b_comp = components.get("B").unwrap(); + let c_comp = components.get("C").unwrap(); + let d_comp = components.get("D").unwrap(); + assert_eq!(a_comp, b_comp); + assert_eq!(b_comp, c_comp); + assert_eq!(c_comp, d_comp); + } + + #[test] + fn test_connected_components_cycle() { + // Cycle: A -> B -> C -> A (all in same component) + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "A", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.a_id = {A.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // All three cubes in same component (cycle doesn't break connectivity) + assert_eq!(components.len(), 3); + let a_comp = components.get("A").unwrap(); + let b_comp = components.get("B").unwrap(); + let c_comp = components.get("C").unwrap(); + assert_eq!(a_comp, b_comp); + assert_eq!(b_comp, c_comp); + } + + #[test] + fn test_connected_components_empty() { + // Empty graph (no cubes) + let mut graph = MockJoinGraph::new(); + let components = graph.connected_components(); + + // Empty result + assert_eq!(components.len(), 0); + } + + #[test] + fn test_connected_components_caching() { + // Verify that results are cached + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + )]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // First call - computes components + let components1 = graph.connected_components(); + assert_eq!(components1.len(), 1); + + // Second call - uses cache + let components2 = graph.connected_components(); + assert_eq!(components2.len(), 1); + assert_eq!(components1.get("A"), components2.get("A")); + + // Recompile - cache should be invalidated + graph.compile(&cubes, &evaluator).unwrap(); + + // Third call - recomputes after cache invalidation + let components3 = graph.connected_components(); + assert_eq!(components3.len(), 1); + } + + #[test] + fn test_connected_components_multiple_groups() { + // Three separate components: {A, B}, {C, D}, {E} + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "D", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.d_id = {D.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("D") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("E") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("D".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("E".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + let components = graph.connected_components(); + + // All five cubes should have component IDs + assert_eq!(components.len(), 5); + + // A and B in same component + let a_comp = components.get("A").unwrap(); + let b_comp = components.get("B").unwrap(); + assert_eq!(a_comp, b_comp); + + // C and D in same component + let c_comp = components.get("C").unwrap(); + let d_comp = components.get("D").unwrap(); + assert_eq!(c_comp, d_comp); + + // E in its own component + let e_comp = components.get("E").unwrap(); + + // All three components are different + assert_ne!(a_comp, c_comp); + assert_ne!(a_comp, e_comp); + assert_ne!(c_comp, e_comp); + } } From 7d3a66693f7d3c2bff8735811096289dc45419f2 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 01:59:40 +0100 Subject: [PATCH 08/13] Refactor tests to reduce duplication --- .../cube_bridge/mock_join_graph.rs | 2510 +---------------- .../cube_bridge/mock_join_graph_tests.rs | 1535 ++++++++++ .../src/test_fixtures/cube_bridge/mod.rs | 3 + 3 files changed, 1546 insertions(+), 2502 deletions(-) create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index 1bc69d5a0a6e4..b4f1c0057df7e 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -75,25 +75,25 @@ pub struct JoinEdge { pub struct MockJoinGraph { /// Directed graph: source -> destination -> weight /// Represents the directed join relationships between cubes - nodes: HashMap>, + pub(crate) nodes: HashMap>, /// Undirected graph: destination -> source -> weight /// Used for connectivity checks and pathfinding - undirected_nodes: HashMap>, + pub(crate) undirected_nodes: HashMap>, /// Edge lookup: "from-to" -> JoinEdge /// Maps edge keys to their corresponding join definitions - edges: HashMap, + pub(crate) edges: HashMap, /// Cache of built join trees: serialized cubes -> JoinDefinition /// Stores previously computed join paths for reuse /// Uses RefCell for interior mutability (allows caching through &self) - built_joins: RefCell>>, + pub(crate) built_joins: RefCell>>, /// Cache for connected components /// Stores the connected component ID for each cube /// None until first calculation - cached_connected_components: Option>, + pub(crate) cached_connected_components: Option>, } impl MockJoinGraph { @@ -132,7 +132,7 @@ impl MockJoinGraph { /// let key = MockJoinGraph::edge_key("orders", "users"); /// assert_eq!(key, "orders-users"); /// ``` - fn edge_key(from: &str, to: &str) -> String { + pub(crate) fn edge_key(from: &str, to: &str) -> String { format!("{}-{}", from, to) } @@ -569,7 +569,7 @@ impl MockJoinGraph { /// # Returns /// * `true` if this join multiplies rows for the cube /// * `false` otherwise - fn check_if_cube_multiplied(&self, cube: &str, join: &JoinEdge) -> bool { + pub(crate) fn check_if_cube_multiplied(&self, cube: &str, join: &JoinEdge) -> bool { let relationship = &join.join.static_data().relationship; (join.from == cube && relationship == "hasMany") @@ -603,7 +603,7 @@ impl MockJoinGraph { /// assert!(graph.find_multiplication_factor_for("users", &joins)); /// assert!(!graph.find_multiplication_factor_for("orders", &joins)); /// ``` - fn find_multiplication_factor_for(&self, cube: &str, joins: &[JoinEdge]) -> bool { + pub(crate) fn find_multiplication_factor_for(&self, cube: &str, joins: &[JoinEdge]) -> bool { use std::collections::HashSet; let mut visited: HashSet = HashSet::new(); @@ -834,2497 +834,3 @@ impl JoinGraph for MockJoinGraph { Ok(result as Rc) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::cube_bridge::evaluator::CubeEvaluator; - use crate::test_fixtures::cube_bridge::{ - MockDimensionDefinition, MockMeasureDefinition, MockSchemaBuilder, - }; - - #[test] - fn test_mock_join_graph_new() { - let graph = MockJoinGraph::new(); - - // Verify all fields are empty - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); - assert!(graph.edges.is_empty()); - assert!(graph.built_joins.borrow().is_empty()); - assert!(graph.cached_connected_components.is_none()); - } - - #[test] - fn test_edge_key_format() { - let key = MockJoinGraph::edge_key("orders", "users"); - assert_eq!(key, "orders-users"); - - let key2 = MockJoinGraph::edge_key("users", "countries"); - assert_eq!(key2, "users-countries"); - - // Verify different order creates different key - let key3 = MockJoinGraph::edge_key("users", "orders"); - assert_ne!(key, key3); - } - - #[test] - fn test_join_edge_creation() { - let join_def = Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ); - - let edge = JoinEdge { - join: join_def.clone(), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "Orders".to_string(), - original_to: "Users".to_string(), - }; - - assert_eq!(edge.from, "orders"); - assert_eq!(edge.to, "users"); - assert_eq!(edge.original_from, "Orders"); - assert_eq!(edge.original_to, "Users"); - assert_eq!(edge.join.static_data().relationship, "many_to_one"); - } - - #[test] - fn test_default_trait() { - let graph = MockJoinGraph::default(); - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); - } - - #[test] - fn test_clone_trait() { - let graph = MockJoinGraph::new(); - let cloned = graph.clone(); - - assert!(cloned.nodes.is_empty()); - assert!(cloned.undirected_nodes.is_empty()); - } - - #[test] - fn test_compile_simple_graph() { - // Create schema: orders -> users - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Verify edges contains "orders-users" - assert!(graph.edges.contains_key("orders-users")); - assert_eq!(graph.edges.len(), 1); - - // Verify nodes: both cubes present, "orders" has edge to "users" - assert_eq!(graph.nodes.len(), 2); - assert!(graph.nodes.contains_key("orders")); - assert!(graph.nodes.contains_key("users")); - let orders_destinations = graph.nodes.get("orders").unwrap(); - assert_eq!(orders_destinations.get("users"), Some(&1)); - - // Verify undirected_nodes: {"users": {"orders": 1}} - assert_eq!(graph.undirected_nodes.len(), 1); - assert!(graph.undirected_nodes.contains_key("users")); - let users_connections = graph.undirected_nodes.get("users").unwrap(); - assert_eq!(users_connections.get("orders"), Some(&1)); - } - - #[test] - fn test_compile_multiple_joins() { - // Create schema: orders -> users, orders -> products, products -> categories - let schema = MockSchemaBuilder::new() - .add_cube("categories") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("products") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "categories", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.category_id = {categories.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ) - .add_join( - "products", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.product_id = {products.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("categories".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("products".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Verify all edges present - assert_eq!(graph.edges.len(), 3); - assert!(graph.edges.contains_key("orders-users")); - assert!(graph.edges.contains_key("orders-products")); - assert!(graph.edges.contains_key("products-categories")); - - // Verify nodes correctly structured - all 4 cubes should be present - assert_eq!(graph.nodes.len(), 4); - assert!(graph.nodes.contains_key("orders")); - assert!(graph.nodes.contains_key("products")); - assert!(graph.nodes.contains_key("users")); - assert!(graph.nodes.contains_key("categories")); - - let orders_dests = graph.nodes.get("orders").unwrap(); - assert_eq!(orders_dests.len(), 2); - assert_eq!(orders_dests.get("users"), Some(&1)); - assert_eq!(orders_dests.get("products"), Some(&1)); - - let products_dests = graph.nodes.get("products").unwrap(); - assert_eq!(products_dests.len(), 1); - assert_eq!(products_dests.get("categories"), Some(&1)); - } - - #[test] - fn test_compile_bidirectional() { - // Create schema: A -> B, B -> A - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("one_to_many".to_string()) - .sql("{CUBE}.id = {B.a_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "A", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.a_id = {A.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Verify both directions in edges - assert_eq!(graph.edges.len(), 2); - assert!(graph.edges.contains_key("A-B")); - assert!(graph.edges.contains_key("B-A")); - - // Verify undirected_nodes handles properly - assert_eq!(graph.undirected_nodes.len(), 2); - assert!(graph.undirected_nodes.contains_key("A")); - assert!(graph.undirected_nodes.contains_key("B")); - } - - #[test] - fn test_compile_nonexistent_cube() { - // Create cube A with join to nonexistent B - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - )]; - - let mut graph = MockJoinGraph::new(); - let result = graph.compile(&cubes, &evaluator); - - // Compile should return error - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Cube B doesn't exist")); - } - - #[test] - fn test_compile_missing_primary_key() { - // Create cube A with multiplied measure (count) and no primary key - let schema = MockSchemaBuilder::new() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - let result = graph.compile(&cubes, &evaluator); - - // Compile should return error - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("primary key for 'A' is required")); - } - - #[test] - fn test_compile_with_primary_key() { - // Create cube A with multiplied measure and primary key - let schema = MockSchemaBuilder::new() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - let result = graph.compile(&cubes, &evaluator); - - // Compile should succeed - assert!(result.is_ok()); - assert_eq!(graph.edges.len(), 1); - assert!(graph.edges.contains_key("A-B")); - } - - #[test] - fn test_recompile_clears_state() { - // Compile with schema A -> B - let schema1 = MockSchemaBuilder::new() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator1 = schema1.create_evaluator(); - let cubes1: Vec> = vec![ - Rc::new( - evaluator1 - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator1 - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes1, &evaluator1).unwrap(); - assert_eq!(graph.edges.len(), 1); - assert!(graph.edges.contains_key("A-B")); - - // Recompile with schema C -> D - let schema2 = MockSchemaBuilder::new() - .add_cube("D") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "D", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.d_id = {D.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator2 = schema2.create_evaluator(); - let cubes2: Vec> = vec![ - Rc::new( - evaluator2 - .cube_from_path("D".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator2 - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - graph.compile(&cubes2, &evaluator2).unwrap(); - - // Verify old edges gone - assert!(!graph.edges.contains_key("A-B")); - - // Verify only new edges present - assert_eq!(graph.edges.len(), 1); - assert!(graph.edges.contains_key("C-D")); - } - - #[test] - fn test_compile_empty() { - // Compile with empty cube list - let schema = MockSchemaBuilder::new().build(); - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Verify all HashMaps empty - assert!(graph.edges.is_empty()); - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); - assert!(graph.built_joins.borrow().is_empty()); - } - - // Tests for build_join functionality - - #[test] - fn test_build_join_simple() { - // Schema: A -> B - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A -> B - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("B".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=A, joins=[A->B], no multiplication - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 1); - - let join_static = joins[0].static_data(); - assert_eq!(join_static.from, "A"); - assert_eq!(join_static.to, "B"); - - // Check multiplication factors - let mult_factors = result.static_data().multiplication_factor.clone(); - assert_eq!(mult_factors.get("A"), Some(&false)); - assert_eq!(mult_factors.get("B"), Some(&false)); - } - - #[test] - fn test_build_join_chain() { - // Schema: A -> B -> C - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A -> B -> C - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("B".to_string()), - JoinHintItem::Single("C".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=A, joins=[A->B, B->C] - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 2); - - assert_eq!(joins[0].static_data().from, "A"); - assert_eq!(joins[0].static_data().to, "B"); - assert_eq!(joins[1].static_data().from, "B"); - assert_eq!(joins[1].static_data().to, "C"); - } - - #[test] - fn test_build_join_shortest_path() { - // Schema: A -> B -> C (2 hops) - // A -> C (1 hop - shortest) - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A, C - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("C".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: use direct path A->C (not A->B->C) - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 1); - - assert_eq!(joins[0].static_data().from, "A"); - assert_eq!(joins[0].static_data().to, "C"); - } - - #[test] - fn test_build_join_star_pattern() { - // Schema: A -> B, A -> C, A -> D - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .add_join( - "D", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.d_id = {D.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("D") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("D".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A, B, C, D - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("B".to_string()), - JoinHintItem::Single("C".to_string()), - JoinHintItem::Single("D".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=A, joins to all others - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 3); - - // All joins should be from A - assert_eq!(joins[0].static_data().from, "A"); - assert_eq!(joins[1].static_data().from, "A"); - assert_eq!(joins[2].static_data().from, "A"); - } - - #[test] - fn test_build_join_disconnected() { - // Schema: A -> B, C -> D (no connection) - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "D", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.d_id = {D.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("D") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("D".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A, D (disconnected) - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("D".to_string()), - ]; - let result = graph.build_join(cubes_to_join); - - // Expected: error "Can't find join path" - assert!(result.is_err()); - let err_msg = result.unwrap_err().message; - assert!(err_msg.contains("Can't find join path")); - assert!(err_msg.contains("'A'")); - assert!(err_msg.contains("'D'")); - } - - #[test] - fn test_build_join_empty() { - let schema = MockSchemaBuilder::new().build(); - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join with empty list - let cubes_to_join = vec![]; - let result = graph.build_join(cubes_to_join); - - // Expected: error - assert!(result.is_err()); - let err_msg = result.unwrap_err().message; - assert!(err_msg.contains("empty")); - } - - #[test] - fn test_build_join_single_cube() { - // Schema: A - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - )]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join with single cube - let cubes_to_join = vec![JoinHintItem::Single("A".to_string())]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=A, no joins - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 0); - } - - #[test] - fn test_build_join_caching() { - // Schema: A -> B - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join twice - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("B".to_string()), - ]; - let result1 = graph.build_join(cubes_to_join.clone()).unwrap(); - let result2 = graph.build_join(cubes_to_join).unwrap(); - - // Verify same Rc returned (pointer equality) - assert!(Rc::ptr_eq(&result1, &result2)); - } - - #[test] - fn test_multiplication_factor_has_many() { - // users hasMany orders - // users should multiply, orders should not - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); - } - - #[test] - fn test_multiplication_factor_belongs_to() { - // orders belongsTo users - // users should multiply, orders should not - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); - } - - #[test] - fn test_multiplication_factor_transitive() { - // users hasMany orders, orders hasMany items - // users multiplies (direct hasMany) - // orders multiplies (has hasMany to items) - // items does not multiply - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {items.order_id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "items".to_string(), - original_from: "orders".to_string(), - original_to: "items".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(graph.find_multiplication_factor_for("orders", &joins)); - assert!(!graph.find_multiplication_factor_for("items", &joins)); - } - - #[test] - fn test_multiplication_factor_many_to_one() { - // orders many_to_one users (neither multiplies) - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }]; - - assert!(!graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); - } - - #[test] - fn test_multiplication_factor_star_pattern() { - // users hasMany orders, users hasMany sessions - // In this graph topology: - // - users multiplies (has hasMany to unvisited nodes) - // - orders multiplies (connected to users which has hasMany to sessions) - // - sessions multiplies (connected to users which has hasMany to orders) - // This is because the algorithm checks for multiplication in the connected component - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {sessions.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "sessions".to_string(), - original_from: "users".to_string(), - original_to: "sessions".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - // orders and sessions both return true because users (connected node) has hasMany - assert!(graph.find_multiplication_factor_for("orders", &joins)); - assert!(graph.find_multiplication_factor_for("sessions", &joins)); - } - - #[test] - fn test_multiplication_factor_cycle() { - // A hasMany B, B hasMany A (cycle) - // Both should multiply - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {B.a_id}".to_string()) - .build(), - ), - from: "A".to_string(), - to: "B".to_string(), - original_from: "A".to_string(), - original_to: "B".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {A.b_id}".to_string()) - .build(), - ), - from: "B".to_string(), - to: "A".to_string(), - original_from: "B".to_string(), - original_to: "A".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("A", &joins)); - assert!(graph.find_multiplication_factor_for("B", &joins)); - } - - #[test] - fn test_multiplication_factor_empty_joins() { - // No joins, no multiplication - let graph = MockJoinGraph::new(); - let joins = vec![]; - - assert!(!graph.find_multiplication_factor_for("users", &joins)); - } - - #[test] - fn test_multiplication_factor_disconnected() { - // orders hasMany items (users disconnected) - // users not in join tree, should not multiply - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {items.order_id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "items".to_string(), - original_from: "orders".to_string(), - original_to: "items".to_string(), - }]; - - assert!(!graph.find_multiplication_factor_for("users", &joins)); - } - - #[test] - fn test_build_join_with_multiplication_factors() { - // Schema: users hasMany orders, orders many_to_one products - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "orders", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "products", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.product_id = {products.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("products") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("products".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: users -> orders -> products - let cubes_to_join = vec![ - JoinHintItem::Single("users".to_string()), - JoinHintItem::Single("orders".to_string()), - JoinHintItem::Single("products".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Check multiplication factors - let mult_factors = result.static_data().multiplication_factor.clone(); - - // users hasMany orders -> users multiplies - assert_eq!(mult_factors.get("users"), Some(&true)); - - // orders is in the middle, does not have its own hasMany, does not multiply - assert_eq!(mult_factors.get("orders"), Some(&false)); - - // products is leaf with many_to_one, does not multiply - assert_eq!(mult_factors.get("products"), Some(&false)); - } - - #[test] - fn test_check_if_cube_multiplied() { - let graph = MockJoinGraph::new(); - - // hasMany: from side multiplies - let join_has_many = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }; - - assert!(graph.check_if_cube_multiplied("users", &join_has_many)); - assert!(!graph.check_if_cube_multiplied("orders", &join_has_many)); - - // belongsTo: to side multiplies - let join_belongs_to = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }; - - assert!(graph.check_if_cube_multiplied("users", &join_belongs_to)); - assert!(!graph.check_if_cube_multiplied("orders", &join_belongs_to)); - - // many_to_one: no multiplication - let join_many_to_one = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }; - - assert!(!graph.check_if_cube_multiplied("users", &join_many_to_one)); - assert!(!graph.check_if_cube_multiplied("orders", &join_many_to_one)); - } - - #[test] - fn test_build_join_with_vector_hint() { - // Schema: A -> B -> C - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join with Vector hint: [A, B] becomes root=A, join to B, then join to C - let cubes_to_join = vec![ - JoinHintItem::Vector(vec!["A".to_string(), "B".to_string()]), - JoinHintItem::Single("C".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=A, joins=[A->B, B->C] - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 2); - - assert_eq!(joins[0].static_data().from, "A"); - assert_eq!(joins[0].static_data().to, "B"); - assert_eq!(joins[1].static_data().from, "B"); - assert_eq!(joins[1].static_data().to, "C"); - } - - #[test] - fn test_connected_components_simple() { - // Graph: users -> orders (both in same component) - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // Both cubes should be in same component - assert_eq!(components.len(), 2); - let users_comp = components.get("users").unwrap(); - let orders_comp = components.get("orders").unwrap(); - assert_eq!(users_comp, orders_comp); - } - - #[test] - fn test_connected_components_disconnected() { - // Graph: users -> orders, products (isolated) - // Two components: {users, orders}, {products} - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("products") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("products".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All three cubes should have component IDs - assert_eq!(components.len(), 3); - - // users and orders in same component - let users_comp = components.get("users").unwrap(); - let orders_comp = components.get("orders").unwrap(); - assert_eq!(users_comp, orders_comp); - - // products in different component - let products_comp = components.get("products").unwrap(); - assert_ne!(users_comp, products_comp); - } - - #[test] - fn test_connected_components_all_isolated() { - // Graph: A, B, C (no joins) - // Three components: {A}, {B}, {C} - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All three cubes in different components - assert_eq!(components.len(), 3); - let a_comp = components.get("A").unwrap(); - let b_comp = components.get("B").unwrap(); - let c_comp = components.get("C").unwrap(); - assert_ne!(a_comp, b_comp); - assert_ne!(b_comp, c_comp); - assert_ne!(a_comp, c_comp); - } - - #[test] - fn test_connected_components_large_connected() { - // Chain: A -> B -> C -> D (all in same component) - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "D", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.d_id = {D.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("D") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("D".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All four cubes in same component - assert_eq!(components.len(), 4); - let a_comp = components.get("A").unwrap(); - let b_comp = components.get("B").unwrap(); - let c_comp = components.get("C").unwrap(); - let d_comp = components.get("D").unwrap(); - assert_eq!(a_comp, b_comp); - assert_eq!(b_comp, c_comp); - assert_eq!(c_comp, d_comp); - } - - #[test] - fn test_connected_components_cycle() { - // Cycle: A -> B -> C -> A (all in same component) - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "A", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.a_id = {A.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All three cubes in same component (cycle doesn't break connectivity) - assert_eq!(components.len(), 3); - let a_comp = components.get("A").unwrap(); - let b_comp = components.get("B").unwrap(); - let c_comp = components.get("C").unwrap(); - assert_eq!(a_comp, b_comp); - assert_eq!(b_comp, c_comp); - } - - #[test] - fn test_connected_components_empty() { - // Empty graph (no cubes) - let mut graph = MockJoinGraph::new(); - let components = graph.connected_components(); - - // Empty result - assert_eq!(components.len(), 0); - } - - #[test] - fn test_connected_components_caching() { - // Verify that results are cached - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - )]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // First call - computes components - let components1 = graph.connected_components(); - assert_eq!(components1.len(), 1); - - // Second call - uses cache - let components2 = graph.connected_components(); - assert_eq!(components2.len(), 1); - assert_eq!(components1.get("A"), components2.get("A")); - - // Recompile - cache should be invalidated - graph.compile(&cubes, &evaluator).unwrap(); - - // Third call - recomputes after cache invalidation - let components3 = graph.connected_components(); - assert_eq!(components3.len(), 1); - } - - #[test] - fn test_connected_components_multiple_groups() { - // Three separate components: {A, B}, {C, D}, {E} - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "D", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.d_id = {D.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("D") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("E") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("D".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("E".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All five cubes should have component IDs - assert_eq!(components.len(), 5); - - // A and B in same component - let a_comp = components.get("A").unwrap(); - let b_comp = components.get("B").unwrap(); - assert_eq!(a_comp, b_comp); - - // C and D in same component - let c_comp = components.get("C").unwrap(); - let d_comp = components.get("D").unwrap(); - assert_eq!(c_comp, d_comp); - - // E in its own component - let e_comp = components.get("E").unwrap(); - - // All three components are different - assert_ne!(a_comp, c_comp); - assert_ne!(a_comp, e_comp); - assert_ne!(c_comp, e_comp); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs new file mode 100644 index 0000000000000..1a17490f21a15 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs @@ -0,0 +1,1535 @@ +use crate::cube_bridge::evaluator::CubeEvaluator; +use crate::cube_bridge::join_definition::JoinDefinition; +use crate::cube_bridge::join_hints::JoinHintItem; +use crate::test_fixtures::cube_bridge::{ + JoinEdge, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, + MockJoinItemDefinition, MockJoinGraph, MockMeasureDefinition, MockSchemaBuilder, +}; +use cubenativeutils::CubeError; +use std::collections::HashMap; +use std::rc::Rc; + +/// Creates comprehensive test schema covering all join graph test scenarios +/// +/// This schema includes multiple independent subgraphs designed to test different +/// join patterns and relationships: +/// +/// 1. **Simple Join (orders -> users)** +/// - For: simple join tests, basic compilation +/// - Cubes: orders, users +/// - Join: orders many_to_one users +/// +/// 2. **Chain (products -> categories -> departments)** +/// - For: chain tests, transitive multiplication +/// - Cubes: products, categories, departments +/// - Joins: products -> categories -> departments +/// +/// 3. **Star Pattern (accounts -> [contacts, deals, tasks])** +/// - For: star pattern tests +/// - Cubes: accounts, contacts, deals, tasks +/// - Joins: accounts -> contacts, accounts -> deals, accounts -> tasks +/// +/// 4. **Relationship Variations (companies <-> employees)** +/// - For: hasMany, belongsTo, bidirectional tests +/// - Cubes: companies, employees, projects +/// - Joins: companies hasMany employees, employees belongsTo companies, employees many_to_one projects +/// +/// 5. **Cycle (regions -> countries -> cities -> regions)** +/// - For: cycle detection tests +/// - Cubes: regions, countries, cities +/// - Joins: regions -> countries -> cities -> regions (back to regions) +/// +/// 6. **Disconnected (warehouses, suppliers - no joins between them)** +/// - For: disconnected component tests +/// - Cubes: warehouses, suppliers (isolated) +/// +/// 7. **Validation Scenarios** +/// - orders_with_measures: has measures, has primary key +/// - orders_without_pk: has measures, NO primary key (for error tests) +/// +/// # Returns +/// +/// Tuple of (evaluator, cubes_map) where cubes_map is a HashMap +/// allowing easy access to cubes by name for test setup. +fn create_comprehensive_test_schema() -> ( + Rc, + HashMap>, +) { + let schema = MockSchemaBuilder::new() + // === 1. SIMPLE JOIN: orders -> users === + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + // === 2. CHAIN: products -> categories -> departments === + .add_cube("products") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "categories", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.category_id = {categories.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("categories") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "departments", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.department_id = {departments.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("departments") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + // === 3. STAR: accounts -> [contacts, deals, tasks] === + .add_cube("accounts") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "contacts", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {contacts.account_id}".to_string()) + .build(), + ) + .add_join( + "deals", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {deals.account_id}".to_string()) + .build(), + ) + .add_join( + "tasks", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {tasks.account_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("contacts") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .finish_cube() + .add_cube("deals") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .finish_cube() + .add_cube("tasks") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .finish_cube() + // === 4. BIDIRECTIONAL: companies <-> employees -> projects === + .add_cube("companies") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "employees", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {employees.company_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("employees") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "companies", + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{CUBE}.company_id = {companies.id}".to_string()) + .build(), + ) + .add_join( + "projects", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.project_id = {projects.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("projects") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + // === 5. CYCLE: regions -> countries -> cities -> regions === + .add_cube("regions") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "countries", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {countries.region_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("countries") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "cities", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {cities.country_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("cities") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "regions", + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{CUBE}.region_id = {regions.id}".to_string()) + .build(), + ) + .finish_cube() + // === 6. DISCONNECTED: warehouses, suppliers (isolated) === + .add_cube("warehouses") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("suppliers") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + // === 7. VALIDATION: orders_with_measures (has PK), orders_without_pk (no PK) === + .add_cube("orders_with_measures") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .finish_cube() + .add_cube("orders_without_pk") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + // NO primary_key set + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{orders_without_pk}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + + // Build cubes map for easy access by name + let cube_names = vec![ + "orders", + "users", + "products", + "categories", + "departments", + "accounts", + "contacts", + "deals", + "tasks", + "companies", + "employees", + "projects", + "regions", + "countries", + "cities", + "warehouses", + "suppliers", + "orders_with_measures", + "orders_without_pk", + ]; + + let mut cubes_map = HashMap::new(); + for name in cube_names { + let cube = evaluator + .cube_from_path(name.to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(); + cubes_map.insert(name.to_string(), Rc::new(cube)); + } + + (evaluator, cubes_map) +} + +/// Extracts a subset of cubes from the cubes map by name +/// +/// # Arguments +/// * `cubes_map` - HashMap of all available cubes +/// * `cube_names` - Names of cubes to extract +/// +/// # Returns +/// Vec of cubes in the order specified by cube_names +fn get_cubes_vec( + cubes_map: &HashMap>, + cube_names: &[&str], +) -> Vec> { + cube_names + .iter() + .map(|name| cubes_map.get(*name).unwrap().clone()) + .collect() +} + +/// Creates and compiles a join graph from specified cubes +/// +/// # Arguments +/// * `cubes_map` - HashMap of all available cubes +/// * `cube_names` - Names of cubes to include in graph +/// * `evaluator` - Cube evaluator +/// +/// # Returns +/// Compiled MockJoinGraph +fn compile_test_graph( + cubes_map: &HashMap>, + cube_names: &[&str], + evaluator: &Rc, +) -> Result { + let cubes = get_cubes_vec(cubes_map, cube_names); + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, evaluator)?; + Ok(graph) +} + +#[test] +fn test_mock_join_graph_new() { + let graph = MockJoinGraph::new(); + + // Verify all fields are empty + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); + assert!(graph.edges.is_empty()); + assert!(graph.built_joins.borrow().is_empty()); + assert!(graph.cached_connected_components.is_none()); +} + +#[test] +fn test_edge_key_format() { + let key = MockJoinGraph::edge_key("orders", "users"); + assert_eq!(key, "orders-users"); + + let key2 = MockJoinGraph::edge_key("users", "countries"); + assert_eq!(key2, "users-countries"); + + // Verify different order creates different key + let key3 = MockJoinGraph::edge_key("users", "orders"); + assert_ne!(key, key3); +} + +#[test] +fn test_join_edge_creation() { + let join_def = Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ); + + let edge = JoinEdge { + join: join_def.clone(), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "Orders".to_string(), + original_to: "Users".to_string(), + }; + + assert_eq!(edge.from, "orders"); + assert_eq!(edge.to, "users"); + assert_eq!(edge.original_from, "Orders"); + assert_eq!(edge.original_to, "Users"); + assert_eq!(edge.join.static_data().relationship, "many_to_one"); +} + +#[test] +fn test_default_trait() { + let graph = MockJoinGraph::default(); + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); +} + +#[test] +fn test_clone_trait() { + let graph = MockJoinGraph::new(); + let cloned = graph.clone(); + + assert!(cloned.nodes.is_empty()); + assert!(cloned.undirected_nodes.is_empty()); +} + +#[test] +fn test_compile_simple_graph() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); + + // Verify edges contains "orders-users" + assert!(graph.edges.contains_key("orders-users")); + assert_eq!(graph.edges.len(), 1); + + // Verify nodes: both cubes present, "orders" has edge to "users" + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("orders")); + assert!(graph.nodes.contains_key("users")); + let orders_destinations = graph.nodes.get("orders").unwrap(); + assert_eq!(orders_destinations.get("users"), Some(&1)); + + // Verify undirected_nodes: {"users": {"orders": 1}} + assert_eq!(graph.undirected_nodes.len(), 1); + assert!(graph.undirected_nodes.contains_key("users")); + let users_connections = graph.undirected_nodes.get("users").unwrap(); + assert_eq!(users_connections.get("orders"), Some(&1)); +} + +#[test] +fn test_compile_multiple_joins() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + // Use accounts star pattern: accounts -> contacts, accounts -> deals, accounts -> tasks + let graph = compile_test_graph( + &cubes_map, + &["accounts", "contacts", "deals", "tasks"], + &evaluator, + ) + .unwrap(); + + // Verify all edges present + assert_eq!(graph.edges.len(), 3); + assert!(graph.edges.contains_key("accounts-contacts")); + assert!(graph.edges.contains_key("accounts-deals")); + assert!(graph.edges.contains_key("accounts-tasks")); + + // Verify nodes correctly structured - all 4 cubes should be present + assert_eq!(graph.nodes.len(), 4); + assert!(graph.nodes.contains_key("accounts")); + assert!(graph.nodes.contains_key("contacts")); + assert!(graph.nodes.contains_key("deals")); + assert!(graph.nodes.contains_key("tasks")); + + let accounts_dests = graph.nodes.get("accounts").unwrap(); + assert_eq!(accounts_dests.len(), 3); + assert_eq!(accounts_dests.get("contacts"), Some(&1)); + assert_eq!(accounts_dests.get("deals"), Some(&1)); + assert_eq!(accounts_dests.get("tasks"), Some(&1)); +} + +#[test] +fn test_compile_bidirectional() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + // Use companies <-> employees bidirectional relationship + // Note: employees also joins to projects, so include it in compilation + let graph = compile_test_graph( + &cubes_map, + &["companies", "employees", "projects"], + &evaluator, + ) + .unwrap(); + + // Verify both bidirectional edges exist + assert!(graph.edges.contains_key("companies-employees")); + assert!(graph.edges.contains_key("employees-companies")); + + // Also verify the employees -> projects edge + assert!(graph.edges.contains_key("employees-projects")); + assert_eq!(graph.edges.len(), 3); + + // Verify undirected_nodes includes all three cubes + assert_eq!(graph.undirected_nodes.len(), 3); + assert!(graph.undirected_nodes.contains_key("companies")); + assert!(graph.undirected_nodes.contains_key("employees")); + assert!(graph.undirected_nodes.contains_key("projects")); +} + +#[test] +fn test_compile_nonexistent_cube() { + // Create cube A with join to nonexistent B + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + )]; + + let mut graph = MockJoinGraph::new(); + let result = graph.compile(&cubes, &evaluator); + + // Compile should return error + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("Cube B doesn't exist")); +} + +#[test] +fn test_compile_missing_primary_key() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + // orders_without_pk has measures and hasMany join but no primary key + let cubes = get_cubes_vec(&cubes_map, &["orders_without_pk", "users"]); + + let mut graph = MockJoinGraph::new(); + let result = graph.compile(&cubes, &evaluator); + + // Compile should return error about missing primary key + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err + .message + .contains("primary key for 'orders_without_pk' is required")); +} + +#[test] +fn test_compile_with_primary_key() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + // orders_with_measures has measure with primary key - should compile successfully + let graph = compile_test_graph(&cubes_map, &["orders_with_measures"], &evaluator).unwrap(); + + // Compile should succeed with cube that has measures and primary key + // Single cube with no joins means no edges + assert!(graph.edges.is_empty()); +} + +#[test] +fn test_recompile_clears_state() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + // First compile with orders -> users + let cubes1 = get_cubes_vec(&cubes_map, &["orders", "users"]); + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes1, &evaluator).unwrap(); + assert_eq!(graph.edges.len(), 1); + assert!(graph.edges.contains_key("orders-users")); + + // Recompile with products -> categories -> departments + let cubes2 = get_cubes_vec(&cubes_map, &["products", "categories", "departments"]); + graph.compile(&cubes2, &evaluator).unwrap(); + + // Verify old edges gone + assert!(!graph.edges.contains_key("orders-users")); + + // Verify only new edges present + assert_eq!(graph.edges.len(), 2); + assert!(graph.edges.contains_key("products-categories")); + assert!(graph.edges.contains_key("categories-departments")); +} + +#[test] +fn test_compile_empty() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + let graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); + + // Verify all HashMaps empty + assert!(graph.edges.is_empty()); + assert!(graph.nodes.is_empty()); + assert!(graph.undirected_nodes.is_empty()); + assert!(graph.built_joins.borrow().is_empty()); +} + +// Tests for build_join functionality + +#[test] +fn test_build_join_simple() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); + + // Build join: orders -> users + let cubes_to_join = vec![ + JoinHintItem::Single("orders".to_string()), + JoinHintItem::Single("users".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=orders, joins=[orders->users], no multiplication + assert_eq!(result.static_data().root, "orders"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 1); + + let join_static = joins[0].static_data(); + assert_eq!(join_static.from, "orders"); + assert_eq!(join_static.to, "users"); + + // Check multiplication factors + let mult_factors = result.static_data().multiplication_factor.clone(); + assert_eq!(mult_factors.get("orders"), Some(&false)); + assert_eq!(mult_factors.get("users"), Some(&false)); +} + +#[test] +fn test_build_join_chain() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + + let graph = compile_test_graph( + &cubes_map, + &["products", "categories", "departments"], + &evaluator, + ) + .unwrap(); + + // Build join: products -> categories -> departments + let cubes_to_join = vec![ + JoinHintItem::Single("products".to_string()), + JoinHintItem::Single("categories".to_string()), + JoinHintItem::Single("departments".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=products, joins=[products->categories, categories->departments] + assert_eq!(result.static_data().root, "products"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 2); + + assert_eq!(joins[0].static_data().from, "products"); + assert_eq!(joins[0].static_data().to, "categories"); + assert_eq!(joins[1].static_data().from, "categories"); + assert_eq!(joins[1].static_data().to, "departments"); +} + +#[test] +fn test_build_join_shortest_path() { + // Schema: A -> B -> C (2 hops) + // A -> C (1 hop - shortest) + let schema = MockSchemaBuilder::new() + .add_cube("A") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "B", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.b_id = {B.id}".to_string()) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("B") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "C", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.c_id = {C.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("C") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("A".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("B".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("C".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: A, C + let cubes_to_join = vec![ + JoinHintItem::Single("A".to_string()), + JoinHintItem::Single("C".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: use direct path A->C (not A->B->C) + assert_eq!(result.static_data().root, "A"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 1); + + assert_eq!(joins[0].static_data().from, "A"); + assert_eq!(joins[0].static_data().to, "C"); +} + +#[test] +fn test_build_join_star_pattern() { + // Schema: accounts -> contacts, accounts -> deals, accounts -> tasks + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = compile_test_graph( + &cubes_map, + &["accounts", "contacts", "deals", "tasks"], + &evaluator, + ) + .unwrap(); + + // Build join: accounts, contacts, deals, tasks + let cubes_to_join = vec![ + JoinHintItem::Single("accounts".to_string()), + JoinHintItem::Single("contacts".to_string()), + JoinHintItem::Single("deals".to_string()), + JoinHintItem::Single("tasks".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=accounts, joins to all others + assert_eq!(result.static_data().root, "accounts"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 3); + + // All joins should be from accounts + assert_eq!(joins[0].static_data().from, "accounts"); + assert_eq!(joins[1].static_data().from, "accounts"); + assert_eq!(joins[2].static_data().from, "accounts"); +} + +#[test] +fn test_build_join_disconnected() { + // Schema: warehouses and suppliers are disconnected (no join) + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = + compile_test_graph(&cubes_map, &["warehouses", "suppliers"], &evaluator).unwrap(); + + // Build join: warehouses, suppliers (disconnected) + let cubes_to_join = vec![ + JoinHintItem::Single("warehouses".to_string()), + JoinHintItem::Single("suppliers".to_string()), + ]; + let result = graph.build_join(cubes_to_join); + + // Expected: error "Can't find join path" + assert!(result.is_err()); + let err_msg = result.unwrap_err().message; + assert!(err_msg.contains("Can't find join path")); + assert!(err_msg.contains("'warehouses'")); + assert!(err_msg.contains("'suppliers'")); +} + +#[test] +fn test_build_join_empty() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); + + // Build join with empty list + let cubes_to_join = vec![]; + let result = graph.build_join(cubes_to_join); + + // Expected: error + assert!(result.is_err()); + let err_msg = result.unwrap_err().message; + assert!(err_msg.contains("empty")); +} + +#[test] +fn test_build_join_single_cube() { + // Schema: orders (single cube) + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = compile_test_graph(&cubes_map, &["orders"], &evaluator).unwrap(); + + // Build join with single cube + let cubes_to_join = vec![JoinHintItem::Single("orders".to_string())]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=orders, no joins + assert_eq!(result.static_data().root, "orders"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 0); +} + +#[test] +fn test_build_join_caching() { + // Schema: orders -> users + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); + + // Build join twice + let cubes_to_join = vec![ + JoinHintItem::Single("orders".to_string()), + JoinHintItem::Single("users".to_string()), + ]; + let result1 = graph.build_join(cubes_to_join.clone()).unwrap(); + let result2 = graph.build_join(cubes_to_join).unwrap(); + + // Verify same Rc returned (pointer equality) + assert!(Rc::ptr_eq(&result1, &result2)); +} + +#[test] +fn test_multiplication_factor_has_many() { + // users hasMany orders + // users should multiply, orders should not + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); +} + +#[test] +fn test_multiplication_factor_belongs_to() { + // orders belongsTo users + // users should multiply, orders should not + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); +} + +#[test] +fn test_multiplication_factor_transitive() { + // users hasMany orders, orders hasMany items + // users multiplies (direct hasMany) + // orders multiplies (has hasMany to items) + // items does not multiply + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {items.order_id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "items".to_string(), + original_from: "orders".to_string(), + original_to: "items".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + assert!(graph.find_multiplication_factor_for("orders", &joins)); + assert!(!graph.find_multiplication_factor_for("items", &joins)); +} + +#[test] +fn test_multiplication_factor_many_to_one() { + // orders many_to_one users (neither multiplies) + let graph = MockJoinGraph::new(); + + let joins = vec![JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }]; + + assert!(!graph.find_multiplication_factor_for("users", &joins)); + assert!(!graph.find_multiplication_factor_for("orders", &joins)); +} + +#[test] +fn test_multiplication_factor_star_pattern() { + // users hasMany orders, users hasMany sessions + // In this graph topology: + // - users multiplies (has hasMany to unvisited nodes) + // - orders multiplies (connected to users which has hasMany to sessions) + // - sessions multiplies (connected to users which has hasMany to orders) + // This is because the algorithm checks for multiplication in the connected component + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {sessions.user_id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "sessions".to_string(), + original_from: "users".to_string(), + original_to: "sessions".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("users", &joins)); + // orders and sessions both return true because users (connected node) has hasMany + assert!(graph.find_multiplication_factor_for("orders", &joins)); + assert!(graph.find_multiplication_factor_for("sessions", &joins)); +} + +#[test] +fn test_multiplication_factor_cycle() { + // A hasMany B, B hasMany A (cycle) + // Both should multiply + let graph = MockJoinGraph::new(); + + let joins = vec![ + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {B.a_id}".to_string()) + .build(), + ), + from: "A".to_string(), + to: "B".to_string(), + original_from: "A".to_string(), + original_to: "B".to_string(), + }, + JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {A.b_id}".to_string()) + .build(), + ), + from: "B".to_string(), + to: "A".to_string(), + original_from: "B".to_string(), + original_to: "A".to_string(), + }, + ]; + + assert!(graph.find_multiplication_factor_for("A", &joins)); + assert!(graph.find_multiplication_factor_for("B", &joins)); +} + +#[test] +fn test_build_join_with_multiplication_factors() { + // Schema: users hasMany orders, orders many_to_one products + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "orders", + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{CUBE}.id = {orders.user_id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_join( + "products", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.product_id = {products.id}".to_string()) + .build(), + ) + .finish_cube() + .add_cube("products") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .build(); + + let evaluator = schema.create_evaluator(); + let cubes: Vec> = vec![ + Rc::new( + evaluator + .cube_from_path("users".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("orders".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + Rc::new( + evaluator + .cube_from_path("products".to_string()) + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .clone(), + ), + ]; + + let mut graph = MockJoinGraph::new(); + graph.compile(&cubes, &evaluator).unwrap(); + + // Build join: users -> orders -> products + let cubes_to_join = vec![ + JoinHintItem::Single("users".to_string()), + JoinHintItem::Single("orders".to_string()), + JoinHintItem::Single("products".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Check multiplication factors + let mult_factors = result.static_data().multiplication_factor.clone(); + + // users hasMany orders -> users multiplies + assert_eq!(mult_factors.get("users"), Some(&true)); + + // orders is in the middle, does not have its own hasMany, does not multiply + assert_eq!(mult_factors.get("orders"), Some(&false)); + + // products is leaf with many_to_one, does not multiply + assert_eq!(mult_factors.get("products"), Some(&false)); +} + +#[test] +fn test_check_if_cube_multiplied() { + let graph = MockJoinGraph::new(); + + // hasMany: from side multiplies + let join_has_many = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("hasMany".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "users".to_string(), + to: "orders".to_string(), + original_from: "users".to_string(), + original_to: "orders".to_string(), + }; + + assert!(graph.check_if_cube_multiplied("users", &join_has_many)); + assert!(!graph.check_if_cube_multiplied("orders", &join_has_many)); + + // belongsTo: to side multiplies + let join_belongs_to = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("belongsTo".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }; + + assert!(graph.check_if_cube_multiplied("users", &join_belongs_to)); + assert!(!graph.check_if_cube_multiplied("orders", &join_belongs_to)); + + // many_to_one: no multiplication + let join_many_to_one = JoinEdge { + join: Rc::new( + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{orders.user_id} = {users.id}".to_string()) + .build(), + ), + from: "orders".to_string(), + to: "users".to_string(), + original_from: "orders".to_string(), + original_to: "users".to_string(), + }; + + assert!(!graph.check_if_cube_multiplied("users", &join_many_to_one)); + assert!(!graph.check_if_cube_multiplied("orders", &join_many_to_one)); +} + +#[test] +fn test_build_join_with_vector_hint() { + // Schema: products -> categories -> departments + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let graph = compile_test_graph( + &cubes_map, + &["products", "categories", "departments"], + &evaluator, + ) + .unwrap(); + + // Build join with Vector hint: [products, categories] becomes root=products, join to categories, then join to departments + let cubes_to_join = vec![ + JoinHintItem::Vector(vec!["products".to_string(), "categories".to_string()]), + JoinHintItem::Single("departments".to_string()), + ]; + let result = graph.build_join(cubes_to_join).unwrap(); + + // Expected: root=products, joins=[products->categories, categories->departments] + assert_eq!(result.static_data().root, "products"); + let joins = result.joins().unwrap(); + assert_eq!(joins.len(), 2); + + assert_eq!(joins[0].static_data().from, "products"); + assert_eq!(joins[0].static_data().to, "categories"); + assert_eq!(joins[1].static_data().from, "categories"); + assert_eq!(joins[1].static_data().to, "departments"); +} + +#[test] +fn test_connected_components_simple() { + // Graph: orders -> users (both in same component) + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); + + let components = graph.connected_components(); + + // Both cubes should be in same component + assert_eq!(components.len(), 2); + let orders_comp = components.get("orders").unwrap(); + let users_comp = components.get("users").unwrap(); + assert_eq!(orders_comp, users_comp); +} + +#[test] +fn test_connected_components_disconnected() { + // Graph: orders -> users (connected), warehouses, suppliers (both isolated) + // Three components: {orders, users}, {warehouses}, {suppliers} + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph( + &cubes_map, + &["orders", "users", "warehouses", "suppliers"], + &evaluator, + ) + .unwrap(); + + let components = graph.connected_components(); + + // All four cubes should have component IDs + assert_eq!(components.len(), 4); + + // orders and users in same component + let orders_comp = components.get("orders").unwrap(); + let users_comp = components.get("users").unwrap(); + assert_eq!(orders_comp, users_comp); + + // warehouses and suppliers in different components + let warehouses_comp = components.get("warehouses").unwrap(); + let suppliers_comp = components.get("suppliers").unwrap(); + assert_ne!(orders_comp, warehouses_comp); + assert_ne!(orders_comp, suppliers_comp); + assert_ne!(warehouses_comp, suppliers_comp); +} + +#[test] +fn test_connected_components_all_isolated() { + // Graph: warehouses, suppliers, orders_with_measures (no joins) + // Three components: {warehouses}, {suppliers}, {orders_with_measures} + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph( + &cubes_map, + &["warehouses", "suppliers", "orders_with_measures"], + &evaluator, + ) + .unwrap(); + + let components = graph.connected_components(); + + // All three cubes in different components + assert_eq!(components.len(), 3); + let warehouses_comp = components.get("warehouses").unwrap(); + let suppliers_comp = components.get("suppliers").unwrap(); + let orders_with_measures_comp = components.get("orders_with_measures").unwrap(); + assert_ne!(warehouses_comp, suppliers_comp); + assert_ne!(suppliers_comp, orders_with_measures_comp); + assert_ne!(warehouses_comp, orders_with_measures_comp); +} + +#[test] +fn test_connected_components_large_connected() { + // Chain: products -> categories -> departments (all in same component) + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph( + &cubes_map, + &["products", "categories", "departments"], + &evaluator, + ) + .unwrap(); + + let components = graph.connected_components(); + + // All cubes in same component + assert_eq!(components.len(), 3); + let products_comp = components.get("products").unwrap(); + let categories_comp = components.get("categories").unwrap(); + let departments_comp = components.get("departments").unwrap(); + + assert_eq!(products_comp, categories_comp); + assert_eq!(categories_comp, departments_comp); +} + +#[test] +fn test_connected_components_cycle() { + // Cycle: regions -> countries -> cities -> regions + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = + compile_test_graph(&cubes_map, &["regions", "countries", "cities"], &evaluator) + .unwrap(); + + let components = graph.connected_components(); + + // All three in same component (cycle) + assert_eq!(components.len(), 3); + let regions_comp = components.get("regions").unwrap(); + let countries_comp = components.get("countries").unwrap(); + let cities_comp = components.get("cities").unwrap(); + + assert_eq!(regions_comp, countries_comp); + assert_eq!(countries_comp, cities_comp); +} + +#[test] +fn test_connected_components_empty() { + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); + + let components = graph.connected_components(); + + // Empty graph + assert_eq!(components.len(), 0); +} + +#[test] +fn test_connected_components_caching() { + // Verify components are cached + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); + + // First call calculates + let components1 = graph.connected_components(); + + // Second call should return cached result + let components2 = graph.connected_components(); + + assert_eq!(components1, components2); +} + +#[test] +fn test_connected_components_multiple_groups() { + // Three disconnected groups: + // - orders -> users + // - warehouses + // - suppliers + let (evaluator, cubes_map) = create_comprehensive_test_schema(); + let mut graph = compile_test_graph( + &cubes_map, + &["orders", "users", "warehouses", "suppliers"], + &evaluator, + ) + .unwrap(); + + let components = graph.connected_components(); + + assert_eq!(components.len(), 4); + + let orders_comp = components.get("orders").unwrap(); + let users_comp = components.get("users").unwrap(); + let warehouses_comp = components.get("warehouses").unwrap(); + let suppliers_comp = components.get("suppliers").unwrap(); + + // orders and users connected + assert_eq!(orders_comp, users_comp); + + // Others disconnected + assert_ne!(orders_comp, warehouses_comp); + assert_ne!(orders_comp, suppliers_comp); + assert_ne!(warehouses_comp, suppliers_comp); +} 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 c7e3c3ad99907..4c05e3fc9fcb9 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -31,6 +31,9 @@ mod mock_sql_utils; mod mock_struct_with_sql_member; mod mock_timeshift_definition; +#[cfg(test)] +mod mock_join_graph_tests; + pub use mock_base_tools::MockBaseTools; pub use mock_case_definition::MockCaseDefinition; pub use mock_case_else_item::MockCaseElseItem; From 0b08b394e63dc3503423111c78be1fd5039672b8 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 02:16:14 +0100 Subject: [PATCH 09/13] Integrate MockJoinGraph with MockSchema --- .../cube_bridge/mock_evaluator.rs | 37 ++++ .../cube_bridge/mock_join_graph_tests.rs | 10 +- .../test_fixtures/cube_bridge/mock_schema.rs | 162 +++++++++++++++++- 3 files changed, 201 insertions(+), 8 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs index d8efa3ab26098..575056a191dc4 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs @@ -8,6 +8,7 @@ use crate::cube_bridge::pre_aggregation_description::PreAggregationDescription; use crate::cube_bridge::segment_definition::SegmentDefinition; use crate::impl_static_data; use crate::test_fixtures::cube_bridge::mock_schema::MockSchema; +use crate::test_fixtures::cube_bridge::MockJoinGraph; use cubenativeutils::CubeError; use std::any::Any; use std::collections::HashMap; @@ -17,6 +18,7 @@ use std::rc::Rc; pub struct MockCubeEvaluator { schema: MockSchema, primary_keys: HashMap>, + join_graph: Option>, } impl MockCubeEvaluator { @@ -25,6 +27,7 @@ impl MockCubeEvaluator { Self { schema, primary_keys: HashMap::new(), + join_graph: None, } } @@ -36,9 +39,43 @@ impl MockCubeEvaluator { Self { schema, primary_keys, + join_graph: None, } } + /// Create a new MockCubeEvaluator with join graph + /// + /// This constructor creates an evaluator with a compiled join graph, + /// enabling join path resolution in tests. + /// + /// # Arguments + /// * `schema` - The mock schema containing cubes + /// * `primary_keys` - Primary key definitions for cubes + /// * `join_graph` - Compiled join graph + /// + /// # Returns + /// * `MockCubeEvaluator` - Evaluator with compiled join graph + pub fn with_join_graph( + schema: MockSchema, + primary_keys: HashMap>, + join_graph: MockJoinGraph, + ) -> Self { + Self { + schema, + primary_keys, + join_graph: Some(Rc::new(join_graph)), + } + } + + /// Get the join graph if available + /// + /// # Returns + /// * `Some(Rc)` - If join graph was created + /// * `None` - If evaluator was created without join graph + pub fn join_graph(&self) -> Option> { + self.join_graph.clone() + } + /// Get all measures for a cube pub fn measures_for_cube( &self, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs index 1a17490f21a15..9288a9cea0209 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs @@ -2,8 +2,8 @@ use crate::cube_bridge::evaluator::CubeEvaluator; use crate::cube_bridge::join_definition::JoinDefinition; use crate::cube_bridge::join_hints::JoinHintItem; use crate::test_fixtures::cube_bridge::{ - JoinEdge, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, - MockJoinItemDefinition, MockJoinGraph, MockMeasureDefinition, MockSchemaBuilder, + JoinEdge, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockJoinGraph, + MockJoinItemDefinition, MockMeasureDefinition, MockSchemaBuilder, }; use cubenativeutils::CubeError; use std::collections::HashMap; @@ -919,8 +919,7 @@ fn test_build_join_star_pattern() { fn test_build_join_disconnected() { // Schema: warehouses and suppliers are disconnected (no join) let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = - compile_test_graph(&cubes_map, &["warehouses", "suppliers"], &evaluator).unwrap(); + let graph = compile_test_graph(&cubes_map, &["warehouses", "suppliers"], &evaluator).unwrap(); // Build join: warehouses, suppliers (disconnected) let cubes_to_join = vec![ @@ -1461,8 +1460,7 @@ fn test_connected_components_cycle() { // Cycle: regions -> countries -> cities -> regions let (evaluator, cubes_map) = create_comprehensive_test_schema(); let mut graph = - compile_test_graph(&cubes_map, &["regions", "countries", "cities"], &evaluator) - .unwrap(); + compile_test_graph(&cubes_map, &["regions", "countries", "cities"], &evaluator).unwrap(); let components = graph.connected_components(); 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 b88cdf1747115..1369aeebf7dfc 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 @@ -1,16 +1,19 @@ use crate::test_fixtures::cube_bridge::{ - MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockJoinItemDefinition, - MockMeasureDefinition, MockSegmentDefinition, + MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockJoinGraph, + MockJoinItemDefinition, MockMeasureDefinition, MockSegmentDefinition, }; +use cubenativeutils::CubeError; use std::collections::HashMap; use std::rc::Rc; /// Mock schema containing cubes with their measures and dimensions +#[derive(Clone)] pub struct MockSchema { cubes: HashMap, } /// Single cube with its definition and members +#[derive(Clone)] pub struct MockCube { pub definition: MockCubeDefinition, pub measures: HashMap>, @@ -95,6 +98,84 @@ impl MockSchema { ) -> Rc { Rc::new(MockCubeEvaluator::with_primary_keys(self, primary_keys)) } + + /// Create a MockJoinGraph from this schema + /// + /// This method: + /// 1. Extracts all cubes as Vec> + /// 2. Creates a temporary MockCubeEvaluator for validation + /// 3. Creates and compiles a MockJoinGraph + /// + /// # Returns + /// * `Ok(MockJoinGraph)` - Compiled join graph + /// * `Err(CubeError)` - If join graph compilation fails (invalid joins, missing PKs, etc.) + pub fn create_join_graph(&self) -> Result { + // Collect cubes as Vec> + let cubes: Vec> = self + .cubes + .values() + .map(|mock_cube| Rc::new(mock_cube.definition.clone())) + .collect(); + + // Extract primary keys for evaluator + let mut primary_keys = HashMap::new(); + for (cube_name, cube) in &self.cubes { + let mut pk_dimensions = Vec::new(); + for (dim_name, dimension) in &cube.dimensions { + if dimension.static_data().primary_key == Some(true) { + pk_dimensions.push(dim_name.clone()); + } + } + pk_dimensions.sort(); + if !pk_dimensions.is_empty() { + primary_keys.insert(cube_name.clone(), pk_dimensions); + } + } + + // Clone self for evaluator + let evaluator = MockCubeEvaluator::with_primary_keys(self.clone(), primary_keys); + + // Create and compile join graph + let mut join_graph = MockJoinGraph::new(); + join_graph.compile(&cubes, &evaluator)?; + + Ok(join_graph) + } + + /// Create a MockCubeEvaluator with join graph from this schema + /// + /// This method creates an evaluator with a fully compiled join graph, + /// enabling join path resolution in tests. + /// + /// # Returns + /// * `Ok(Rc)` - Evaluator with join graph + /// * `Err(CubeError)` - If join graph compilation fails + pub fn create_evaluator_with_join_graph(self) -> Result, CubeError> { + // Extract primary keys + let mut primary_keys = HashMap::new(); + for (cube_name, cube) in &self.cubes { + let mut pk_dimensions = Vec::new(); + for (dim_name, dimension) in &cube.dimensions { + if dimension.static_data().primary_key == Some(true) { + pk_dimensions.push(dim_name.clone()); + } + } + pk_dimensions.sort(); + if !pk_dimensions.is_empty() { + primary_keys.insert(cube_name.clone(), pk_dimensions); + } + } + + // Compile join graph + let join_graph = self.create_join_graph()?; + + // Create evaluator with join graph + Ok(Rc::new(MockCubeEvaluator::with_join_graph( + self, + primary_keys, + join_graph, + ))) + } } /// Builder for MockSchema with fluent API @@ -1319,4 +1400,81 @@ mod tests { assert!(orders_cube.definition.get_join("products").is_some()); assert!(orders_cube.definition.get_join("warehouses").is_some()); } + + #[test] + fn test_schema_with_join_graph_integration() { + use crate::cube_bridge::join_graph::JoinGraph; + use crate::cube_bridge::join_hints::JoinHintItem; + + // Small schema: orders -> users (one join) + let schema = MockSchemaBuilder::new() + .add_cube("users") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .finish_cube() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_dimension( + "user_id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("user_id".to_string()) + .build(), + ) + .add_join( + "users", + MockJoinItemDefinition::builder() + .relationship("many_to_one".to_string()) + .sql("{CUBE}.user_id = {users.id}".to_string()) + .build(), + ) + .finish_cube() + .build(); + + // Verify create_join_graph() succeeds + let join_graph_result = schema.create_join_graph(); + assert!( + join_graph_result.is_ok(), + "create_join_graph should succeed" + ); + + // Verify create_evaluator_with_join_graph() succeeds + let evaluator_result = schema.create_evaluator_with_join_graph(); + assert!( + evaluator_result.is_ok(), + "create_evaluator_with_join_graph should succeed" + ); + let evaluator = evaluator_result.unwrap(); + + // Verify evaluator.join_graph() returns Some(graph) + assert!( + evaluator.join_graph().is_some(), + "Evaluator should have join graph" + ); + let graph = evaluator.join_graph().unwrap(); + + // Verify graph.build_join() works + let cubes = vec![ + JoinHintItem::Single("orders".to_string()), + JoinHintItem::Single("users".to_string()), + ]; + let join_def_result = graph.build_join(cubes); + assert!( + join_def_result.is_ok(), + "graph.build_join should succeed for orders -> users" + ); + } } From bf6587415fb8b732eb826e6b495520307b23aec3 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Sat, 15 Nov 2025 02:22:49 +0100 Subject: [PATCH 10/13] lint fix --- .../cube_bridge/mock_join_graph.rs | 18 ++++++++---------- .../test_fixtures/cube_bridge/mock_schema.rs | 1 - 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index b4f1c0057df7e..3b11d884016ae 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -170,7 +170,7 @@ impl MockJoinGraph { if !from_multiplied.is_empty() { let static_data = evaluator.static_data(); let primary_keys = static_data.primary_keys.get(cube_name); - if primary_keys.map_or(true, |pk| pk.is_empty()) { + if primary_keys.is_none_or(|pk| pk.is_empty()) { return Err(CubeError::user(format!( "primary key for '{}' is required when join is defined in order to make aggregates work properly", cube_name @@ -183,7 +183,7 @@ impl MockJoinGraph { if !to_multiplied.is_empty() { let static_data = evaluator.static_data(); let primary_keys = static_data.primary_keys.get(join_name); - if primary_keys.map_or(true, |pk| pk.is_empty()) { + if primary_keys.is_none_or(|pk| pk.is_empty()) { return Err(CubeError::user(format!( "primary key for '{}' is required when join is defined in order to make aggregates work properly", join_name @@ -372,9 +372,7 @@ impl MockJoinGraph { // Find shortest path let path = find_shortest_path(&self.nodes, &prev_node, to_join); - if path.is_none() { - return None; // Can't find path - } + path.as_ref()?; let path = path.unwrap(); @@ -681,7 +679,7 @@ impl MockJoinGraph { // First, ensure all cubes exist in nodes HashMap (even if they have no joins) for cube in cubes { let cube_name = cube.static_data().name.clone(); - self.nodes.entry(cube_name).or_insert_with(HashMap::new); + self.nodes.entry(cube_name).or_default(); } // Build edges from all cubes @@ -694,19 +692,19 @@ impl MockJoinGraph { // Build nodes HashMap (directed graph) // Group edges by 'from' field and create HashMap of destinations - for (_, edge) in &self.edges { + for edge in self.edges.values() { self.nodes .entry(edge.from.clone()) - .or_insert_with(HashMap::new) + .or_default() .insert(edge.to.clone(), 1); } // Build undirected_nodes HashMap // For each edge (from -> to), also add (to -> from) for bidirectional connectivity - for (_, edge) in &self.edges { + for edge in self.edges.values() { self.undirected_nodes .entry(edge.to.clone()) - .or_insert_with(HashMap::new) + .or_default() .insert(edge.from.clone(), 1); } 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 1369aeebf7dfc..0e71c14db0cce 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 @@ -1403,7 +1403,6 @@ mod tests { #[test] fn test_schema_with_join_graph_integration() { - use crate::cube_bridge::join_graph::JoinGraph; use crate::cube_bridge::join_hints::JoinHintItem; // Small schema: orders -> users (one join) From 83b0804dc58e5ffbb1ca92e6e9ba264de9f629a2 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 17 Nov 2025 13:37:16 +0100 Subject: [PATCH 11/13] remove comments --- .../src/test_fixtures/cube_bridge/macros.rs | 40 - .../cube_bridge/mock_base_tools.rs | 144 -- .../cube_bridge/mock_case_definition.rs | 1 - .../cube_bridge/mock_case_else_item.rs | 1 - .../cube_bridge/mock_case_item.rs | 1 - .../mock_case_switch_definition.rs | 1 - .../cube_bridge/mock_case_switch_else_item.rs | 1 - .../cube_bridge/mock_case_switch_item.rs | 1 - .../cube_bridge/mock_cube_definition.rs | 9 - .../cube_bridge/mock_dimension_definition.rs | 324 ---- .../cube_bridge/mock_driver_tools.rs | 283 --- .../cube_bridge/mock_evaluator.rs | 417 ----- .../cube_bridge/mock_expression_struct.rs | 68 - .../cube_bridge/mock_geo_item.rs | 1 - .../mock_granularity_definition.rs | 1 - .../cube_bridge/mock_join_definition.rs | 168 -- .../cube_bridge/mock_join_graph.rs | 135 -- .../cube_bridge/mock_join_graph_tests.rs | 1533 ----------------- .../src/test_fixtures/cube_bridge/mod.rs | 3 - 19 files changed, 3132 deletions(-) delete mode 100644 rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/macros.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/macros.rs index 2b99022d69fed..07beaa6d05c6b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/macros.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/macros.rs @@ -3,28 +3,6 @@ /// This macro generates a helper method that returns an owned StaticData struct. /// The helper is used by the trait's static_data() method which applies Box::leak. /// -/// # Usage -/// ```ignore -/// impl_static_data!( -/// MockDimensionDefinition, // The mock type -/// DimensionDefinitionStatic, // The static data type -/// dimension_type, // Fields to include -/// owned_by_cube, -/// multi_stage -/// ); -/// ``` -/// -/// # Generated Code -/// ```ignore -/// impl MockDimensionDefinition { -/// pub fn static_data(&self) -> DimensionDefinitionStatic { -/// DimensionDefinitionStatic { -/// dimension_type: self.dimension_type.clone(), -/// owned_by_cube: self.owned_by_cube.clone(), -/// multi_stage: self.multi_stage.clone(), -/// } -/// } -/// } /// ``` #[macro_export] macro_rules! impl_static_data { @@ -54,24 +32,6 @@ macro_rules! impl_static_data { /// - The leaked memory is minimal and reclaimed when the test process exits /// - This approach significantly simplifies test code by avoiding complex lifetime management /// -/// # Usage -/// ```ignore -/// impl DimensionDefinition for MockDimensionDefinition { -/// impl_static_data_method!(DimensionDefinitionStatic); -/// -/// fn sql(&self) -> Result>, CubeError> { -/// // ... other trait methods -/// } -/// } -/// ``` -/// -/// # Generated Code -/// ```ignore -/// fn static_data(&self) -> &DimensionDefinitionStatic { -/// // Intentional memory leak - acceptable for test mocks -/// // The Box::leak pattern converts the owned value to a static reference -/// Box::leak(Box::new(Self::static_data(self))) -/// } /// ``` #[macro_export] macro_rules! impl_static_data_method { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs index 6397dd12a2a75..d453a905286ac 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs @@ -20,21 +20,6 @@ use typed_builder::TypedBuilder; /// security_context_for_rust, and sql_utils_for_rust. /// Other methods throw todo!() errors. /// -/// # Example -/// -/// ``` -/// use cubesqlplanner::test_fixtures::cube_bridge::MockBaseTools; -/// -/// // Use builder pattern -/// let tools = MockBaseTools::builder().build(); -/// let driver_tools = tools.driver_tools(false).unwrap(); -/// let sql_templates = tools.sql_templates().unwrap(); -/// -/// // Or with custom components -/// let custom_driver = MockDriverTools::with_timezone("Europe/London".to_string()); -/// let tools = MockBaseTools::builder() -/// .driver_tools(custom_driver) -/// .build(); /// ``` #[derive(Clone, TypedBuilder)] pub struct MockBaseTools { @@ -62,27 +47,22 @@ impl BaseTools for MockBaseTools { self } - /// Returns driver tools - uses MockDriverTools fn driver_tools(&self, _external: bool) -> Result, CubeError> { Ok(self.driver_tools.clone()) } - /// Returns SQL templates renderer - uses MockSqlTemplatesRender fn sql_templates(&self) -> Result, CubeError> { Ok(self.sql_templates.clone()) } - /// Returns security context - uses MockSecurityContext fn security_context_for_rust(&self) -> Result, CubeError> { Ok(self.security_context.clone()) } - /// Returns SQL utils - uses MockSqlUtils fn sql_utils_for_rust(&self) -> Result, CubeError> { Ok(self.sql_utils.clone()) } - /// Generate time series - not implemented in mock fn generate_time_series( &self, _granularity: String, @@ -91,7 +71,6 @@ impl BaseTools for MockBaseTools { todo!("generate_time_series not implemented in mock") } - /// Generate custom time series - not implemented in mock fn generate_custom_time_series( &self, _granularity: String, @@ -101,22 +80,18 @@ impl BaseTools for MockBaseTools { todo!("generate_custom_time_series not implemented in mock") } - /// Get allocated parameters - not implemented in mock fn get_allocated_params(&self) -> Result, CubeError> { todo!("get_allocated_params not implemented in mock") } - /// Get all cube members - not implemented in mock fn all_cube_members(&self, _path: String) -> Result, CubeError> { todo!("all_cube_members not implemented in mock") } - /// Get interval and minimal time unit - not implemented in mock fn interval_and_minimal_time_unit(&self, _interval: String) -> Result, CubeError> { todo!("interval_and_minimal_time_unit not implemented in mock") } - /// Get pre-aggregation by name - not implemented in mock fn get_pre_aggregation_by_name( &self, _cube_name: String, @@ -125,7 +100,6 @@ impl BaseTools for MockBaseTools { todo!("get_pre_aggregation_by_name not implemented in mock") } - /// Get pre-aggregation table name - not implemented in mock fn pre_aggregation_table_name( &self, _cube_name: String, @@ -134,7 +108,6 @@ impl BaseTools for MockBaseTools { todo!("pre_aggregation_table_name not implemented in mock") } - /// Get join tree for hints - not implemented in mock fn join_tree_for_hints( &self, _hints: Vec, @@ -142,120 +115,3 @@ impl BaseTools for MockBaseTools { todo!("join_tree_for_hints not implemented in mock") } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_builder_default() { - let tools = MockBaseTools::builder().build(); - assert!(tools.driver_tools(false).is_ok()); - assert!(tools.sql_templates().is_ok()); - assert!(tools.security_context_for_rust().is_ok()); - assert!(tools.sql_utils_for_rust().is_ok()); - } - - #[test] - fn test_default_trait() { - let tools = MockBaseTools::default(); - assert!(tools.driver_tools(false).is_ok()); - assert!(tools.sql_templates().is_ok()); - assert!(tools.security_context_for_rust().is_ok()); - assert!(tools.sql_utils_for_rust().is_ok()); - } - - #[test] - fn test_driver_tools() { - let tools = MockBaseTools::builder().build(); - let driver_tools = tools.driver_tools(false).unwrap(); - - // Test that it returns a valid DriverTools implementation - let result = driver_tools - .time_grouped_column("day".to_string(), "created_at".to_string()) - .unwrap(); - assert_eq!(result, "date_trunc('day', created_at)"); - } - - #[test] - fn test_driver_tools_external_flag() { - let tools = MockBaseTools::builder().build(); - - // Both external true and false should work (mock ignores the flag) - assert!(tools.driver_tools(false).is_ok()); - assert!(tools.driver_tools(true).is_ok()); - } - - #[test] - fn test_sql_templates() { - let tools = MockBaseTools::builder().build(); - let templates = tools.sql_templates().unwrap(); - - // Test that it returns a valid SqlTemplatesRender implementation - assert!(templates.contains_template("filters/equals")); - assert!(templates.contains_template("functions/SUM")); - } - - #[test] - fn test_security_context() { - let tools = MockBaseTools::builder().build(); - // Just verify it returns without error - assert!(tools.security_context_for_rust().is_ok()); - } - - #[test] - fn test_sql_utils() { - let tools = MockBaseTools::builder().build(); - // Just verify it returns without error - assert!(tools.sql_utils_for_rust().is_ok()); - } - - #[test] - fn test_builder_with_custom_driver_tools() { - let custom_driver = MockDriverTools::with_timezone("Europe/London".to_string()); - let tools = MockBaseTools::builder() - .driver_tools(Rc::new(custom_driver)) - .build(); - - let driver_tools = tools.driver_tools(false).unwrap(); - let result = driver_tools.convert_tz("timestamp".to_string()).unwrap(); - assert_eq!( - result, - "(timestamp::timestamptz AT TIME ZONE 'Europe/London')" - ); - } - - #[test] - fn test_builder_with_custom_sql_templates() { - let mut custom_templates = std::collections::HashMap::new(); - custom_templates.insert("test/template".to_string(), "TEST {{value}}".to_string()); - let sql_templates = MockSqlTemplatesRender::try_new(custom_templates).unwrap(); - - let tools = MockBaseTools::builder() - .sql_templates(Rc::new(sql_templates)) - .build(); - - let templates = tools.sql_templates().unwrap(); - assert!(templates.contains_template("test/template")); - } - - #[test] - fn test_builder_with_all_custom_components() { - let driver_tools = MockDriverTools::with_timezone("Asia/Tokyo".to_string()); - let sql_templates = MockSqlTemplatesRender::default_templates(); - let security_context = MockSecurityContext; - let sql_utils = MockSqlUtils; - - let tools = MockBaseTools::builder() - .driver_tools(Rc::new(driver_tools)) - .sql_templates(Rc::new(sql_templates)) - .security_context(Rc::new(security_context)) - .sql_utils(Rc::new(sql_utils)) - .build(); - - assert!(tools.driver_tools(false).is_ok()); - assert!(tools.sql_templates().is_ok()); - assert!(tools.security_context_for_rust().is_ok()); - assert!(tools.sql_utils_for_rust().is_ok()); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_definition.rs index 2351975d53a8e..cc81ae109d1fc 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_definition.rs @@ -7,7 +7,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseDefinition for testing #[derive(TypedBuilder)] pub struct MockCaseDefinition { when: Vec>, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_else_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_else_item.rs index 820de6f753871..eb789c3050a73 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_else_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_else_item.rs @@ -5,7 +5,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseElseItem for testing #[derive(Debug, Clone, TypedBuilder)] pub struct MockCaseElseItem { label: StringOrSql, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_item.rs index f857ea1ef796d..1f348991b1c49 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_item.rs @@ -7,7 +7,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseItem for testing #[derive(Debug, Clone, TypedBuilder)] pub struct MockCaseItem { sql: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_definition.rs index 05d3792e46e31..3c04b91dddb76 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_definition.rs @@ -10,7 +10,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseSwitchDefinition for testing #[derive(TypedBuilder)] pub struct MockCaseSwitchDefinition { switch: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_else_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_else_item.rs index cf67520fc15ea..c6cfb093d07a4 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_else_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_else_item.rs @@ -6,7 +6,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseSwitchElseItem for testing #[derive(Debug, Clone, TypedBuilder)] pub struct MockCaseSwitchElseItem { sql: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_item.rs index 7f7ca43b6e63c..709d29d233334 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_case_switch_item.rs @@ -7,7 +7,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CaseSwitchItem for testing #[derive(Debug, Clone, TypedBuilder)] pub struct MockCaseSwitchItem { value: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs index 0772568569f5b..e2ca93151992f 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs @@ -8,10 +8,8 @@ use std::collections::HashMap; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of CubeDefinition for testing #[derive(Clone, TypedBuilder)] pub struct MockCubeDefinition { - // Fields from CubeDefinitionStatic name: String, #[builder(default)] sql_alias: Option, @@ -22,13 +20,11 @@ pub struct MockCubeDefinition { #[builder(default)] join_map: Option>>, - // Optional trait fields #[builder(default, setter(strip_option))] sql_table: Option, #[builder(default, setter(strip_option))] sql: Option, - // Joins field for mock testing #[builder(default)] joins: HashMap, } @@ -74,12 +70,10 @@ impl CubeDefinition for MockCubeDefinition { } impl MockCubeDefinition { - /// Get all joins for this cube pub fn joins(&self) -> &HashMap { &self.joins } - /// Get a specific join by name pub fn get_join(&self, name: &str) -> Option<&MockJoinItemDefinition> { self.joins.get(name) } @@ -261,17 +255,14 @@ mod tests { .joins(joins) .build(); - // Test joins() method let all_joins = cube.joins(); assert_eq!(all_joins.len(), 1); assert!(all_joins.contains_key("countries")); - // Test get_join() method let country_join = cube.get_join("countries").unwrap(); let sql = country_join.sql().unwrap(); assert_eq!(sql.args_names(), &vec!["CUBE", "countries"]); - // Test nonexistent join assert!(cube.get_join("nonexistent").is_none()); } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_dimension_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_dimension_definition.rs index 8245c520e8e72..2a623451f7dda 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_dimension_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_dimension_definition.rs @@ -10,10 +10,8 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of DimensionDefinition for testing #[derive(TypedBuilder)] pub struct MockDimensionDefinition { - // Fields from DimensionDefinitionStatic #[builder(default = "string".to_string())] dimension_type: String, #[builder(default = Some(false))] @@ -31,7 +29,6 @@ pub struct MockDimensionDefinition { #[builder(default)] primary_key: Option, - // Optional trait fields #[builder(default, setter(strip_option))] sql: Option, #[builder(default)] @@ -129,324 +126,3 @@ impl DimensionDefinition for MockDimensionDefinition { self } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_dimension() { - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .sql("{CUBE.name}".to_string()) - .build(); - - assert_eq!(dim.static_data().dimension_type, "string"); - assert!(dim.has_sql().unwrap()); - assert!(dim.sql().unwrap().is_some()); - } - - #[test] - fn test_number_dimension() { - let dim = MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("{CUBE.count}".to_string()) - .build(); - - assert_eq!(dim.static_data().dimension_type, "number"); - assert!(dim.has_sql().unwrap()); - } - - #[test] - fn test_time_dimension() { - let dim = MockDimensionDefinition::builder() - .dimension_type("time".to_string()) - .sql("{CUBE.created_at}".to_string()) - .build(); - - assert_eq!(dim.static_data().dimension_type, "time"); - assert!(dim.has_sql().unwrap()); - } - - #[test] - fn test_geo_dimension() { - let dim = MockDimensionDefinition::builder() - .dimension_type("geo".to_string()) - .latitude("{CUBE.lat}".to_string()) - .longitude("{CUBE.lon}".to_string()) - .build(); - - assert_eq!(dim.static_data().dimension_type, "geo"); - assert!(dim.has_latitude().unwrap()); - assert!(dim.has_longitude().unwrap()); - assert!(!dim.has_sql().unwrap()); - } - - #[test] - fn test_switch_dimension() { - let dim = MockDimensionDefinition::builder() - .dimension_type("switch".to_string()) - .values(Some(vec!["active".to_string(), "inactive".to_string()])) - .build(); - - assert_eq!(dim.static_data().dimension_type, "switch"); - assert_eq!( - dim.static_data().values, - Some(vec!["active".to_string(), "inactive".to_string()]) - ); - assert!(!dim.has_sql().unwrap()); - } - - #[test] - fn test_dimension_with_time_shift() { - let time_shift = Rc::new( - MockTimeShiftDefinition::builder() - .interval(Some("1 day".to_string())) - .name(Some("yesterday".to_string())) - .build(), - ); - - let dim = MockDimensionDefinition::builder() - .dimension_type("time".to_string()) - .sql("{CUBE.date}".to_string()) - .time_shift(Some(vec![time_shift])) - .build(); - - assert!(dim.has_time_shift().unwrap()); - let shifts = dim.time_shift().unwrap().unwrap(); - assert_eq!(shifts.len(), 1); - } - - #[test] - fn test_dimension_with_flags() { - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .sql("{CUBE.field}".to_string()) - .multi_stage(Some(true)) - .sub_query(Some(true)) - .owned_by_cube(Some(false)) - .build(); - - assert_eq!(dim.static_data().multi_stage, Some(true)); - assert_eq!(dim.static_data().sub_query, Some(true)); - assert_eq!(dim.static_data().owned_by_cube, Some(false)); - } - - #[test] - fn test_sql_parsing_simple() { - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .sql("{CUBE.field}".to_string()) - .build(); - - let sql = dim.sql().unwrap().unwrap(); - assert_eq!(sql.args_names(), &vec!["CUBE"]); - - // Check compiled template - use crate::test_fixtures::cube_bridge::{MockSecurityContext, MockSqlUtils}; - let (template, args) = sql - .compile_template_sql(Rc::new(MockSqlUtils), Rc::new(MockSecurityContext)) - .unwrap(); - - match template { - crate::cube_bridge::member_sql::SqlTemplate::String(s) => { - assert_eq!(s, "{arg:0}"); - } - _ => panic!("Expected String template"), - } - - assert_eq!(args.symbol_paths.len(), 1); - assert_eq!(args.symbol_paths[0], vec!["CUBE", "field"]); - } - - #[test] - fn test_sql_parsing_multiple_refs() { - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .sql("{CUBE.first_name} || ' ' || {CUBE.last_name}".to_string()) - .build(); - - let sql = dim.sql().unwrap().unwrap(); - assert_eq!(sql.args_names(), &vec!["CUBE"]); - - // Check compiled template - use crate::test_fixtures::cube_bridge::{MockSecurityContext, MockSqlUtils}; - let (template, args) = sql - .compile_template_sql(Rc::new(MockSqlUtils), Rc::new(MockSecurityContext)) - .unwrap(); - - match template { - crate::cube_bridge::member_sql::SqlTemplate::String(s) => { - assert_eq!(s, "{arg:0} || ' ' || {arg:1}"); - } - _ => panic!("Expected String template"), - } - - assert_eq!(args.symbol_paths.len(), 2); - assert_eq!(args.symbol_paths[0], vec!["CUBE", "first_name"]); - assert_eq!(args.symbol_paths[1], vec!["CUBE", "last_name"]); - } - - #[test] - fn test_sql_parsing_cross_cube_refs() { - let dim = MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("{CUBE.amount} / {other_cube.total}".to_string()) - .build(); - - let sql = dim.sql().unwrap().unwrap(); - assert_eq!(sql.args_names(), &vec!["CUBE", "other_cube"]); - - // Check compiled template - use crate::test_fixtures::cube_bridge::{MockSecurityContext, MockSqlUtils}; - let (template, args) = sql - .compile_template_sql(Rc::new(MockSqlUtils), Rc::new(MockSecurityContext)) - .unwrap(); - - match template { - crate::cube_bridge::member_sql::SqlTemplate::String(s) => { - assert_eq!(s, "{arg:0} / {arg:1}"); - } - _ => panic!("Expected String template"), - } - - assert_eq!(args.symbol_paths.len(), 2); - assert_eq!(args.symbol_paths[0], vec!["CUBE", "amount"]); - assert_eq!(args.symbol_paths[1], vec!["other_cube", "total"]); - } - - #[test] - fn test_geo_sql_parsing() { - let dim = MockDimensionDefinition::builder() - .dimension_type("geo".to_string()) - .latitude("{CUBE.latitude}".to_string()) - .longitude("{CUBE.longitude}".to_string()) - .build(); - - assert!(!dim.has_sql().unwrap()); - - let lat = dim.latitude().unwrap().unwrap(); - let lat_sql = lat.sql().unwrap(); - - use crate::test_fixtures::cube_bridge::{MockSecurityContext, MockSqlUtils}; - let (template, args) = lat_sql - .compile_template_sql(Rc::new(MockSqlUtils), Rc::new(MockSecurityContext)) - .unwrap(); - - match template { - crate::cube_bridge::member_sql::SqlTemplate::String(s) => { - assert_eq!(s, "{arg:0}"); - } - _ => panic!("Expected String template"), - } - - assert_eq!(args.symbol_paths[0], vec!["CUBE", "latitude"]); - } - - #[test] - fn test_case_dimension() { - use crate::cube_bridge::case_variant::CaseVariant; - use crate::cube_bridge::string_or_sql::StringOrSql; - use crate::test_fixtures::cube_bridge::{ - MockCaseDefinition, MockCaseElseItem, MockCaseItem, - }; - - let when_items = vec![ - Rc::new( - MockCaseItem::builder() - .sql("{CUBE.status} = 'active'".to_string()) - .label(StringOrSql::String("Active".to_string())) - .build(), - ), - Rc::new( - MockCaseItem::builder() - .sql("{CUBE.status} = 'inactive'".to_string()) - .label(StringOrSql::String("Inactive".to_string())) - .build(), - ), - ]; - - let else_item = Rc::new( - MockCaseElseItem::builder() - .label(StringOrSql::String("Unknown".to_string())) - .build(), - ); - - let case_def = Rc::new( - MockCaseDefinition::builder() - .when(when_items) - .else_label(else_item) - .build(), - ); - - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .case(Some(Rc::new(CaseVariant::Case(case_def)))) - .build(); - - assert!(dim.has_case().unwrap()); - let case_result = dim.case().unwrap(); - assert!(case_result.is_some()); - - if let Some(CaseVariant::Case(case)) = case_result { - let when = case.when().unwrap(); - assert_eq!(when.len(), 2); - } else { - panic!("Expected Case variant"); - } - } - - #[test] - fn test_case_switch_dimension() { - use crate::cube_bridge::case_variant::CaseVariant; - use crate::test_fixtures::cube_bridge::{ - MockCaseSwitchDefinition, MockCaseSwitchElseItem, MockCaseSwitchItem, - }; - - let when_items = vec![ - Rc::new( - MockCaseSwitchItem::builder() - .value("1".to_string()) - .sql("{CUBE.active_value}".to_string()) - .build(), - ), - Rc::new( - MockCaseSwitchItem::builder() - .value("0".to_string()) - .sql("{CUBE.inactive_value}".to_string()) - .build(), - ), - ]; - - let else_item = Rc::new( - MockCaseSwitchElseItem::builder() - .sql("{CUBE.default_value}".to_string()) - .build(), - ); - - let case_switch = Rc::new( - MockCaseSwitchDefinition::builder() - .switch("{CUBE.status_code}".to_string()) - .when(when_items) - .else_sql(else_item) - .build(), - ); - - let dim = MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .case(Some(Rc::new(CaseVariant::CaseSwitch(case_switch)))) - .build(); - - assert!(dim.has_case().unwrap()); - let case_result = dim.case().unwrap(); - assert!(case_result.is_some()); - - if let Some(CaseVariant::CaseSwitch(case_switch)) = case_result { - assert!(case_switch.switch().is_ok()); - let when = case_switch.when().unwrap(); - assert_eq!(when.len(), 2); - } else { - panic!("Expected CaseSwitch variant"); - } - } -} 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 79274a8cb9b37..ac433277748a4 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 @@ -10,15 +10,6 @@ use std::rc::Rc; /// This mock provides implementations based on PostgresQuery.ts and BaseQuery.js /// from packages/cubejs-schema-compiler/src/adapter/ /// -/// # Example -/// -/// ``` -/// use cubesqlplanner::test_fixtures::cube_bridge::MockDriverTools; -/// -/// let tools = MockDriverTools::new(); -/// let result = tools.time_grouped_column("day".to_string(), "created_at".to_string()).unwrap(); -/// assert_eq!(result, "date_trunc('day', created_at)"); -/// ``` #[derive(Clone)] pub struct MockDriverTools { timezone: String, @@ -27,7 +18,6 @@ pub struct MockDriverTools { } impl MockDriverTools { - /// Creates a new MockDriverTools with default settings (UTC timezone) pub fn new() -> Self { Self { timezone: "UTC".to_string(), @@ -36,7 +26,6 @@ impl MockDriverTools { } } - /// Creates a new MockDriverTools with a specific timezone pub fn with_timezone(timezone: String) -> Self { Self { timezone, @@ -45,7 +34,6 @@ impl MockDriverTools { } } - /// Creates a new MockDriverTools with custom SQL templates #[allow(dead_code)] pub fn with_sql_templates(sql_templates: MockSqlTemplatesRender) -> Self { Self { @@ -67,8 +55,6 @@ impl DriverTools for MockDriverTools { self } - /// Convert timezone - based on PostgresQuery.ts:26-28 - /// Returns: `(field::timestamptz AT TIME ZONE 'timezone')` fn convert_tz(&self, field: String) -> Result { Ok(format!( "({}::timestamptz AT TIME ZONE '{}')", @@ -76,8 +62,6 @@ impl DriverTools for MockDriverTools { )) } - /// Time grouped column - based on PostgresQuery.ts:30-32 - /// Uses date_trunc function with granularity mapping fn time_grouped_column( &self, granularity: String, @@ -104,74 +88,49 @@ impl DriverTools for MockDriverTools { Ok(format!("date_trunc('{}', {})", interval, dimension)) } - /// Returns SQL templates renderer fn sql_templates(&self) -> Result, CubeError> { Ok(self.sql_templates.clone()) } - /// Timestamp precision - based on BaseQuery.js:3834-3836 fn timestamp_precision(&self) -> Result { Ok(self.timestamp_precision) } - /// Timestamp cast - based on BaseQuery.js:2101-2103 - /// Returns: `value::timestamptz` fn time_stamp_cast(&self, field: String) -> Result { Ok(format!("{}::timestamptz", field)) } - /// DateTime cast - based on BaseQuery.js:2105-2107 - /// Returns: `value::timestamp` fn date_time_cast(&self, field: String) -> Result { Ok(format!("{}::timestamp", field)) } - /// Convert date to DB timezone - based on BaseQuery.js:3820-3822 - /// This is a simplified version that returns the date as-is - /// The full implementation would use localTimestampToUtc utility fn in_db_time_zone(&self, date: String) -> Result { - // In real implementation this calls localTimestampToUtc(timezone, timestampFormat(), date) - // For mock we just return the date as-is Ok(date) } - /// Get allocated parameters - returns empty vec for mock fn get_allocated_params(&self) -> Result, CubeError> { Ok(Vec::new()) } - /// Subtract interval - based on BaseQuery.js:1166-1169 - /// Returns: `date - interval 'interval'` fn subtract_interval(&self, date: String, interval: String) -> Result { let interval_str = self.interval_string(interval)?; Ok(format!("{} - interval {}", date, interval_str)) } - /// Add interval - based on BaseQuery.js:1176-1179 - /// Returns: `date + interval 'interval'` fn add_interval(&self, date: String, interval: String) -> Result { let interval_str = self.interval_string(interval)?; Ok(format!("{} + interval {}", date, interval_str)) } - /// Format interval string - based on BaseQuery.js:1190-1192 - /// Returns: `'interval'` fn interval_string(&self, interval: String) -> Result { Ok(format!("'{}'", interval)) } - /// Add timestamp interval - based on BaseQuery.js:1199-1201 - /// Delegates to add_interval fn add_timestamp_interval(&self, date: String, interval: String) -> Result { self.add_interval(date, interval) } - /// Get interval and minimal time unit - based on BaseQuery.js:2116-2119 - /// Returns: [interval, minimal_time_unit] - /// The minimal time unit is the lowest unit in the interval (e.g., "day" for "5 days") fn interval_and_minimal_time_unit(&self, interval: String) -> Result, CubeError> { - // Parse minimal granularity from interval - // This is a simplified version - full implementation would call diffTimeUnitForInterval let min_unit = if interval.contains("second") { "second" } else if interval.contains("minute") { @@ -195,26 +154,18 @@ impl DriverTools for MockDriverTools { Ok(vec![interval, min_unit.to_string()]) } - /// HLL init - based on PostgresQuery.ts:48-50 - /// Returns: `hll_add_agg(hll_hash_any(sql))` fn hll_init(&self, sql: String) -> Result { Ok(format!("hll_add_agg(hll_hash_any({}))", sql)) } - /// HLL merge - based on PostgresQuery.ts:52-54 - /// Returns: `round(hll_cardinality(hll_union_agg(sql)))` fn hll_merge(&self, sql: String) -> Result { Ok(format!("round(hll_cardinality(hll_union_agg({})))", sql)) } - /// HLL cardinality merge - based on BaseQuery.js:3734-3736 - /// Delegates to hll_merge fn hll_cardinality_merge(&self, sql: String) -> Result { self.hll_merge(sql) } - /// Count distinct approx - based on PostgresQuery.ts:56-58 - /// Returns: `round(hll_cardinality(hll_add_agg(hll_hash_any(sql))))` fn count_distinct_approx(&self, sql: String) -> Result { Ok(format!( "round(hll_cardinality(hll_add_agg(hll_hash_any({}))))", @@ -222,15 +173,10 @@ impl DriverTools for MockDriverTools { )) } - /// Support generated series for custom time dimensions - based on PostgresQuery.ts:60-62 - /// Postgres supports this, so returns true fn support_generated_series_for_custom_td(&self) -> Result { Ok(true) } - /// Date bin function - based on PostgresQuery.ts:40-46 - /// Returns sql for source expression floored to timestamps aligned with - /// intervals relative to origin timestamp point fn date_bin( &self, interval: String, @@ -243,232 +189,3 @@ impl DriverTools for MockDriverTools { )) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_convert_tz() { - let tools = MockDriverTools::new(); - let result = tools.convert_tz("created_at".to_string()).unwrap(); - assert_eq!(result, "(created_at::timestamptz AT TIME ZONE 'UTC')"); - } - - #[test] - fn test_convert_tz_with_custom_timezone() { - let tools = MockDriverTools::with_timezone("America/Los_Angeles".to_string()); - let result = tools.convert_tz("created_at".to_string()).unwrap(); - assert_eq!( - result, - "(created_at::timestamptz AT TIME ZONE 'America/Los_Angeles')" - ); - } - - #[test] - fn test_time_grouped_column() { - let tools = MockDriverTools::new(); - - // Test various granularities - assert_eq!( - tools - .time_grouped_column("day".to_string(), "created_at".to_string()) - .unwrap(), - "date_trunc('day', created_at)" - ); - - assert_eq!( - tools - .time_grouped_column("month".to_string(), "updated_at".to_string()) - .unwrap(), - "date_trunc('month', updated_at)" - ); - - assert_eq!( - tools - .time_grouped_column("year".to_string(), "timestamp".to_string()) - .unwrap(), - "date_trunc('year', timestamp)" - ); - } - - #[test] - fn test_time_grouped_column_invalid_granularity() { - let tools = MockDriverTools::new(); - let result = tools.time_grouped_column("invalid".to_string(), "created_at".to_string()); - assert!(result.is_err()); - } - - #[test] - fn test_timestamp_precision() { - let tools = MockDriverTools::new(); - assert_eq!(tools.timestamp_precision().unwrap(), 3); - } - - #[test] - fn test_time_stamp_cast() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.time_stamp_cast("?".to_string()).unwrap(), - "?::timestamptz" - ); - } - - #[test] - fn test_date_time_cast() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.date_time_cast("date_from".to_string()).unwrap(), - "date_from::timestamp" - ); - } - - #[test] - fn test_subtract_interval() { - let tools = MockDriverTools::new(); - assert_eq!( - tools - .subtract_interval("NOW()".to_string(), "1 day".to_string()) - .unwrap(), - "NOW() - interval '1 day'" - ); - } - - #[test] - fn test_add_interval() { - let tools = MockDriverTools::new(); - assert_eq!( - tools - .add_interval("created_at".to_string(), "7 days".to_string()) - .unwrap(), - "created_at + interval '7 days'" - ); - } - - #[test] - fn test_interval_string() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.interval_string("1 hour".to_string()).unwrap(), - "'1 hour'" - ); - } - - #[test] - fn test_add_timestamp_interval() { - let tools = MockDriverTools::new(); - assert_eq!( - tools - .add_timestamp_interval("timestamp".to_string(), "5 minutes".to_string()) - .unwrap(), - "timestamp + interval '5 minutes'" - ); - } - - #[test] - fn test_interval_and_minimal_time_unit() { - let tools = MockDriverTools::new(); - - let result = tools - .interval_and_minimal_time_unit("5 days".to_string()) - .unwrap(); - assert_eq!(result, vec!["5 days", "day"]); - - let result = tools - .interval_and_minimal_time_unit("2 hours".to_string()) - .unwrap(); - assert_eq!(result, vec!["2 hours", "hour"]); - - let result = tools - .interval_and_minimal_time_unit("30 seconds".to_string()) - .unwrap(); - assert_eq!(result, vec!["30 seconds", "second"]); - } - - #[test] - fn test_hll_init() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.hll_init("user_id".to_string()).unwrap(), - "hll_add_agg(hll_hash_any(user_id))" - ); - } - - #[test] - fn test_hll_merge() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.hll_merge("hll_column".to_string()).unwrap(), - "round(hll_cardinality(hll_union_agg(hll_column)))" - ); - } - - #[test] - fn test_hll_cardinality_merge() { - let tools = MockDriverTools::new(); - assert_eq!( - tools.hll_cardinality_merge("hll_data".to_string()).unwrap(), - "round(hll_cardinality(hll_union_agg(hll_data)))" - ); - } - - #[test] - fn test_count_distinct_approx() { - let tools = MockDriverTools::new(); - assert_eq!( - tools - .count_distinct_approx("visitor_id".to_string()) - .unwrap(), - "round(hll_cardinality(hll_add_agg(hll_hash_any(visitor_id))))" - ); - } - - #[test] - fn test_support_generated_series_for_custom_td() { - let tools = MockDriverTools::new(); - assert!(tools.support_generated_series_for_custom_td().unwrap()); - } - - #[test] - fn test_date_bin() { - let tools = MockDriverTools::new(); - let result = tools - .date_bin( - "1 day".to_string(), - "created_at".to_string(), - "2024-01-01".to_string(), - ) - .unwrap(); - - assert_eq!( - result, - "('2024-01-01' ::timestamp + INTERVAL '1 day' * FLOOR(EXTRACT(EPOCH FROM (created_at - '2024-01-01'::timestamp)) / EXTRACT(EPOCH FROM INTERVAL '1 day')))" - ); - } - - #[test] - fn test_in_db_time_zone() { - let tools = MockDriverTools::new(); - let result = tools - .in_db_time_zone("2024-01-01T00:00:00".to_string()) - .unwrap(); - assert_eq!(result, "2024-01-01T00:00:00"); - } - - #[test] - fn test_get_allocated_params() { - let tools = MockDriverTools::new(); - let result = tools.get_allocated_params().unwrap(); - assert_eq!(result, Vec::::new()); - } - - #[test] - fn test_sql_templates() { - let tools = MockDriverTools::new(); - let templates = tools.sql_templates().unwrap(); - - // Verify it returns a valid SqlTemplatesRender - assert!(templates.contains_template("filters/equals")); - assert!(templates.contains_template("functions/SUM")); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs index 575056a191dc4..fe2180b790d90 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs @@ -14,7 +14,6 @@ use std::any::Any; use std::collections::HashMap; use std::rc::Rc; -/// Mock implementation of CubeEvaluator for testing pub struct MockCubeEvaluator { schema: MockSchema, primary_keys: HashMap>, @@ -22,7 +21,6 @@ pub struct MockCubeEvaluator { } impl MockCubeEvaluator { - /// Create a new MockCubeEvaluator with the given schema pub fn new(schema: MockSchema) -> Self { Self { schema, @@ -31,7 +29,6 @@ impl MockCubeEvaluator { } } - /// Create a new MockCubeEvaluator with schema and primary keys pub fn with_primary_keys( schema: MockSchema, primary_keys: HashMap>, @@ -43,18 +40,6 @@ impl MockCubeEvaluator { } } - /// Create a new MockCubeEvaluator with join graph - /// - /// This constructor creates an evaluator with a compiled join graph, - /// enabling join path resolution in tests. - /// - /// # Arguments - /// * `schema` - The mock schema containing cubes - /// * `primary_keys` - Primary key definitions for cubes - /// * `join_graph` - Compiled join graph - /// - /// # Returns - /// * `MockCubeEvaluator` - Evaluator with compiled join graph pub fn with_join_graph( schema: MockSchema, primary_keys: HashMap>, @@ -67,16 +52,10 @@ impl MockCubeEvaluator { } } - /// Get the join graph if available - /// - /// # Returns - /// * `Some(Rc)` - If join graph was created - /// * `None` - If evaluator was created without join graph pub fn join_graph(&self) -> Option> { self.join_graph.clone() } - /// Get all measures for a cube pub fn measures_for_cube( &self, cube_name: &str, @@ -87,8 +66,6 @@ impl MockCubeEvaluator { .unwrap_or_default() } - /// Parse a path string like "cube.member" into ["cube", "member"] - /// Returns error if the path doesn't exist in schema for the given type fn parse_and_validate_path( &self, path_type: &str, @@ -96,7 +73,6 @@ impl MockCubeEvaluator { ) -> Result, CubeError> { let parts: Vec = path.split('.').map(|s| s.to_string()).collect(); - // Allow 2 parts (cube.member) or 3 parts (cube.dimension.granularity for time dimensions) if parts.len() != 2 && parts.len() != 3 { return Err(CubeError::user(format!( "Invalid path format: '{}'. Expected format: 'cube.member' or 'cube.time_dimension.granularity'", @@ -107,14 +83,11 @@ impl MockCubeEvaluator { let cube_name = &parts[0]; let member_name = &parts[1]; - // Check if cube exists if self.schema.get_cube(cube_name).is_none() { return Err(CubeError::user(format!("Cube '{}' not found", cube_name))); } - // If we have 3 parts, check if the dimension is a time dimension if parts.len() == 3 { - // Only dimensions can have granularity if path_type != "dimension" && path_type != "dimensions" { return Err(CubeError::user(format!( "Granularity can only be specified for dimensions, not for {}", @@ -122,7 +95,6 @@ impl MockCubeEvaluator { ))); } - // Check if the dimension exists and is of type 'time' if let Some(dimension) = self.schema.get_dimension(cube_name, member_name) { if dimension.static_data().dimension_type != "time" { return Err(CubeError::user(format!( @@ -131,7 +103,6 @@ impl MockCubeEvaluator { dimension.static_data().dimension_type ))); } - // Granularity is valid - return all 3 parts return Ok(parts); } else { return Err(CubeError::user(format!( @@ -141,7 +112,6 @@ impl MockCubeEvaluator { } } - // For 2-part paths, validate member exists for the given type let exists = match path_type { "measure" | "measures" => self.schema.get_measure(cube_name, member_name).is_some(), "dimension" | "dimensions" => { @@ -269,7 +239,6 @@ impl CubeEvaluator for MockCubeEvaluator { &self, path: Vec, ) -> Result, CubeError> { - // path should be [cube_name, dimension_name, "granularities", granularity] if path.len() != 4 { return Err(CubeError::user(format!( "Invalid granularity path: expected 4 parts (cube.dimension.granularities.granularity), got {}", @@ -286,7 +255,6 @@ impl CubeEvaluator for MockCubeEvaluator { let granularity = &path[3]; - // Validate granularity is one of the supported ones let valid_granularities = [ "second", "minute", "hour", "day", "week", "month", "quarter", "year", ]; @@ -298,7 +266,6 @@ impl CubeEvaluator for MockCubeEvaluator { ))); } - // Create mock granularity definition with interval equal to granularity use crate::test_fixtures::cube_bridge::MockGranularityDefinition; Ok(Rc::new( MockGranularityDefinition::builder() @@ -338,387 +305,3 @@ impl CubeEvaluator for MockCubeEvaluator { self } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_fixtures::cube_bridge::{ - MockDimensionDefinition, MockMeasureDefinition, MockSchemaBuilder, MockSegmentDefinition, - }; - - fn create_test_schema() -> MockSchema { - MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_dimension( - "name", - MockDimensionDefinition::builder() - .dimension_type("string".to_string()) - .sql("name".to_string()) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .add_segment( - "active", - MockSegmentDefinition::builder() - .sql("{CUBE.status} = 'active'".to_string()) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_measure( - "total", - MockMeasureDefinition::builder() - .measure_type("sum".to_string()) - .sql("amount".to_string()) - .build(), - ) - .finish_cube() - .build() - } - - #[test] - fn test_parse_path_measure() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("measure".to_string(), "users.count".to_string()); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["users", "count"]); - } - - #[test] - fn test_parse_path_dimension() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("dimension".to_string(), "users.name".to_string()); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["users", "name"]); - } - - #[test] - fn test_parse_path_segment() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("segment".to_string(), "users.active".to_string()); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["users", "active"]); - } - - #[test] - fn test_parse_path_invalid_format() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("measure".to_string(), "invalid".to_string()); - assert!(result.is_err()); - assert!(result.unwrap_err().message.contains("Invalid path format")); - } - - #[test] - fn test_parse_path_cube_not_found() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("measure".to_string(), "nonexistent.count".to_string()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .message - .contains("Cube 'nonexistent' not found")); - } - - #[test] - fn test_parse_path_member_not_found() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.parse_path("measure".to_string(), "users.nonexistent".to_string()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .message - .contains("measure 'nonexistent' not found")); - } - - #[test] - fn test_measure_by_path() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let measure = evaluator - .measure_by_path("users.count".to_string()) - .unwrap(); - assert_eq!(measure.static_data().measure_type, "count"); - } - - #[test] - fn test_dimension_by_path() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let dimension = evaluator - .dimension_by_path("users.name".to_string()) - .unwrap(); - assert_eq!(dimension.static_data().dimension_type, "string"); - } - - #[test] - fn test_segment_by_path() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let segment = evaluator - .segment_by_path("users.active".to_string()) - .unwrap(); - // Verify it's a valid segment - assert!(segment.sql().is_ok()); - } - - #[test] - fn test_cube_from_path() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let cube = evaluator.cube_from_path("users".to_string()).unwrap(); - assert_eq!(cube.static_data().name, "users"); - } - - #[test] - fn test_cube_from_path_not_found() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.cube_from_path("nonexistent".to_string()); - assert!(result.is_err()); - if let Err(err) = result { - assert!(err.message.contains("Cube 'nonexistent' not found")); - } - } - - #[test] - fn test_is_measure() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - assert!(evaluator - .is_measure(vec!["users".to_string(), "count".to_string()]) - .unwrap()); - assert!(!evaluator - .is_measure(vec!["users".to_string(), "name".to_string()]) - .unwrap()); - assert!(!evaluator - .is_measure(vec!["users".to_string(), "nonexistent".to_string()]) - .unwrap()); - } - - #[test] - fn test_is_dimension() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - assert!(evaluator - .is_dimension(vec!["users".to_string(), "name".to_string()]) - .unwrap()); - assert!(!evaluator - .is_dimension(vec!["users".to_string(), "count".to_string()]) - .unwrap()); - assert!(!evaluator - .is_dimension(vec!["users".to_string(), "nonexistent".to_string()]) - .unwrap()); - } - - #[test] - fn test_is_segment() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - assert!(evaluator - .is_segment(vec!["users".to_string(), "active".to_string()]) - .unwrap()); - assert!(!evaluator - .is_segment(vec!["users".to_string(), "count".to_string()]) - .unwrap()); - assert!(!evaluator - .is_segment(vec!["users".to_string(), "nonexistent".to_string()]) - .unwrap()); - } - - #[test] - fn test_cube_exists() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - assert!(evaluator.cube_exists("users".to_string()).unwrap()); - assert!(evaluator.cube_exists("orders".to_string()).unwrap()); - assert!(!evaluator.cube_exists("nonexistent".to_string()).unwrap()); - } - - #[test] - fn test_with_primary_keys() { - let schema = create_test_schema(); - let mut primary_keys = HashMap::new(); - primary_keys.insert("users".to_string(), vec!["id".to_string()]); - primary_keys.insert( - "orders".to_string(), - vec!["id".to_string(), "user_id".to_string()], - ); - - let evaluator = MockCubeEvaluator::with_primary_keys(schema, primary_keys.clone()); - - let static_data = evaluator.static_data(); - assert_eq!(static_data.primary_keys, primary_keys); - } - - #[test] - fn test_multiple_cubes() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - // Test users cube - assert!(evaluator.cube_exists("users".to_string()).unwrap()); - assert!(evaluator - .is_measure(vec!["users".to_string(), "count".to_string()]) - .unwrap()); - assert!(evaluator - .is_dimension(vec!["users".to_string(), "name".to_string()]) - .unwrap()); - - // Test orders cube - assert!(evaluator.cube_exists("orders".to_string()).unwrap()); - assert!(evaluator - .is_measure(vec!["orders".to_string(), "total".to_string()]) - .unwrap()); - assert!(evaluator - .is_dimension(vec!["orders".to_string(), "id".to_string()]) - .unwrap()); - } - - #[test] - fn test_resolve_granularity() { - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "created_at", - MockDimensionDefinition::builder() - .dimension_type("time".to_string()) - .sql("created_at".to_string()) - .build(), - ) - .finish_cube() - .build(); - let evaluator = MockCubeEvaluator::new(schema); - - // Test valid granularities with 4-part path: [cube, dimension, "granularities", granularity] - let granularities = vec![ - "second", "minute", "hour", "day", "week", "month", "quarter", "year", - ]; - for gran in granularities { - let result = evaluator.resolve_granularity(vec![ - "users".to_string(), - "created_at".to_string(), - "granularities".to_string(), - gran.to_string(), - ]); - assert!(result.is_ok()); - let granularity_def = result.unwrap(); - assert_eq!(granularity_def.static_data().interval, gran); - assert_eq!(granularity_def.static_data().origin, None); - assert_eq!(granularity_def.static_data().offset, None); - } - } - - #[test] - fn test_resolve_granularity_invalid_path_length() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.resolve_granularity(vec![ - "users".to_string(), - "created_at".to_string(), - "granularities".to_string(), - ]); - assert!(result.is_err()); - if let Err(err) = result { - assert!(err.message.contains("expected 4 parts")); - } - } - - #[test] - fn test_resolve_granularity_unsupported() { - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "created_at", - MockDimensionDefinition::builder() - .dimension_type("time".to_string()) - .sql("created_at".to_string()) - .build(), - ) - .finish_cube() - .build(); - let evaluator = MockCubeEvaluator::new(schema); - - let result = evaluator.resolve_granularity(vec![ - "users".to_string(), - "created_at".to_string(), - "granularities".to_string(), - "invalid".to_string(), - ]); - assert!(result.is_err()); - if let Err(err) = result { - assert!(err.message.contains("Unsupported granularity")); - } - } - - #[test] - #[should_panic(expected = "pre_aggregations_for_cube_as_array is not implemented")] - fn test_pre_aggregations_for_cube_panics() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let _ = evaluator.pre_aggregations_for_cube_as_array("users".to_string()); - } - - #[test] - #[should_panic(expected = "pre_aggregation_description_by_name is not implemented")] - fn test_pre_aggregation_by_name_panics() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - let _ = - evaluator.pre_aggregation_description_by_name("users".to_string(), "main".to_string()); - } - - #[test] - #[should_panic(expected = "evaluate_rollup_references is not implemented")] - fn test_evaluate_rollup_references_panics() { - let schema = create_test_schema(); - let evaluator = MockCubeEvaluator::new(schema); - - use crate::test_fixtures::cube_bridge::MockMemberSql; - let sql = Rc::new(MockMemberSql::new("{CUBE.id}").unwrap()); - let _ = evaluator.evaluate_rollup_references("users".to_string(), sql); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_expression_struct.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_expression_struct.rs index 9f10d02066baa..e31e136ed25fe 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_expression_struct.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_expression_struct.rs @@ -7,17 +7,14 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of ExpressionStruct for testing #[derive(TypedBuilder)] pub struct MockExpressionStruct { - // Fields from ExpressionStructStatic expression_type: String, #[builder(default)] source_measure: Option, #[builder(default)] replace_aggregation_type: Option, - // Optional trait fields #[builder(default)] add_filters: Option>>, } @@ -54,68 +51,3 @@ impl ExpressionStruct for MockExpressionStruct { self } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_basic_expression_struct() { - let expr = MockExpressionStruct::builder() - .expression_type("aggregate".to_string()) - .build(); - - assert_eq!(expr.static_data().expression_type, "aggregate"); - assert!(!expr.has_add_filters().unwrap()); - } - - #[test] - fn test_expression_struct_with_source_measure() { - let expr = MockExpressionStruct::builder() - .expression_type("measure_reference".to_string()) - .source_measure(Some("users.count".to_string())) - .build(); - - let static_data = expr.static_data(); - assert_eq!(static_data.source_measure, Some("users.count".to_string())); - } - - #[test] - fn test_expression_struct_with_replace_aggregation() { - let expr = MockExpressionStruct::builder() - .expression_type("aggregate".to_string()) - .replace_aggregation_type(Some("avg".to_string())) - .build(); - - let static_data = expr.static_data(); - assert_eq!( - static_data.replace_aggregation_type, - Some("avg".to_string()) - ); - } - - #[test] - fn test_expression_struct_with_add_filters() { - let filters = vec![ - Rc::new( - MockStructWithSqlMember::builder() - .sql("{CUBE.status} = 'active'".to_string()) - .build(), - ), - Rc::new( - MockStructWithSqlMember::builder() - .sql("{CUBE.deleted} = false".to_string()) - .build(), - ), - ]; - - let expr = MockExpressionStruct::builder() - .expression_type("aggregate".to_string()) - .add_filters(Some(filters)) - .build(); - - assert!(expr.has_add_filters().unwrap()); - let result = expr.add_filters().unwrap().unwrap(); - assert_eq!(result.len(), 2); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_geo_item.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_geo_item.rs index 80be311aa8ed1..dfa9e9560dfec 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_geo_item.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_geo_item.rs @@ -6,7 +6,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of GeoItem for testing #[derive(Debug, TypedBuilder)] pub struct MockGeoItem { sql: String, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_granularity_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_granularity_definition.rs index 805854ea535c1..db762c779661a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_granularity_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_granularity_definition.rs @@ -8,7 +8,6 @@ use std::any::Any; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of GranularityDefinition for testing #[derive(Clone, TypedBuilder)] pub struct MockGranularityDefinition { #[builder(setter(into))] diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs index 0c70ba4cb650d..118d2aa49c922 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_definition.rs @@ -8,15 +8,12 @@ use std::collections::HashMap; use std::rc::Rc; use typed_builder::TypedBuilder; -/// Mock implementation of JoinDefinition for testing #[derive(Debug, TypedBuilder)] pub struct MockJoinDefinition { - // Fields from JoinDefinitionStatic root: String, #[builder(default)] multiplication_factor: HashMap, - // Trait field joins: Vec>, } @@ -42,168 +39,3 @@ impl JoinDefinition for MockJoinDefinition { self } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_fixtures::cube_bridge::MockJoinItemDefinition; - - #[test] - fn test_basic_join_definition() { - let join_item = Rc::new( - MockJoinItem::builder() - .from("orders".to_string()) - .to("users".to_string()) - .original_from("Orders".to_string()) - .original_to("Users".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - )) - .build(), - ); - - let join_def = MockJoinDefinition::builder() - .root("orders".to_string()) - .joins(vec![join_item]) - .build(); - - assert_eq!(join_def.static_data().root, "orders"); - let joins = join_def.joins().unwrap(); - assert_eq!(joins.len(), 1); - } - - #[test] - fn test_join_definition_with_multiplication_factor() { - let mut mult_factor = HashMap::new(); - mult_factor.insert("orders".to_string(), true); - mult_factor.insert("users".to_string(), false); - - let join_def = MockJoinDefinition::builder() - .root("orders".to_string()) - .multiplication_factor(mult_factor.clone()) - .joins(vec![]) - .build(); - - assert_eq!(join_def.static_data().multiplication_factor, mult_factor); - } - - #[test] - fn test_join_definition_with_multiple_joins() { - let join_to_users = Rc::new( - MockJoinItem::builder() - .from("orders".to_string()) - .to("users".to_string()) - .original_from("Orders".to_string()) - .original_to("Users".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - )) - .build(), - ); - - let join_to_products = Rc::new( - MockJoinItem::builder() - .from("orders".to_string()) - .to("products".to_string()) - .original_from("Orders".to_string()) - .original_to("Products".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.product_id} = {products.id}".to_string()) - .build(), - )) - .build(), - ); - - let join_def = MockJoinDefinition::builder() - .root("orders".to_string()) - .joins(vec![join_to_users, join_to_products]) - .build(); - - let joins = join_def.joins().unwrap(); - assert_eq!(joins.len(), 2); - assert_eq!(joins[0].static_data().to, "users"); - assert_eq!(joins[1].static_data().to, "products"); - } - - #[test] - fn test_complex_join_graph() { - // Orders -> Users - let join_orders_users = Rc::new( - MockJoinItem::builder() - .from("orders".to_string()) - .to("users".to_string()) - .original_from("Orders".to_string()) - .original_to("Users".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - )) - .build(), - ); - - // Users -> Countries - let join_users_countries = Rc::new( - MockJoinItem::builder() - .from("users".to_string()) - .to("countries".to_string()) - .original_from("Users".to_string()) - .original_to("Countries".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{users.country_id} = {countries.id}".to_string()) - .build(), - )) - .build(), - ); - - // Orders -> Products - let join_orders_products = Rc::new( - MockJoinItem::builder() - .from("orders".to_string()) - .to("products".to_string()) - .original_from("Orders".to_string()) - .original_to("Products".to_string()) - .join(Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_many".to_string()) - .sql("{orders.id} = {order_items.order_id} AND {order_items.product_id} = {products.id}".to_string()) - .build(), - )) - .build(), - ); - - let mut mult_factor = HashMap::new(); - mult_factor.insert("products".to_string(), true); - - let join_def = MockJoinDefinition::builder() - .root("orders".to_string()) - .joins(vec![ - join_orders_users, - join_users_countries, - join_orders_products, - ]) - .multiplication_factor(mult_factor) - .build(); - - let static_data = join_def.static_data(); - assert_eq!(static_data.root, "orders"); - assert_eq!( - static_data.multiplication_factor.get("products"), - Some(&true) - ); - - let joins = join_def.joins().unwrap(); - assert_eq!(joins.len(), 3); - } -} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index 3b11d884016ae..fc41cc59a5eea 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -15,41 +15,13 @@ use std::rc::Rc; /// the current routing (from/to) and the original cube names (original_from/original_to). /// This distinction is important when dealing with cube aliases. /// -/// # Example -/// -/// ``` -/// use cubesqlplanner::test_fixtures::cube_bridge::{JoinEdge, MockJoinItemDefinition}; -/// use std::rc::Rc; -/// -/// let join_def = Rc::new( -/// MockJoinItemDefinition::builder() -/// .relationship("many_to_one".to_string()) -/// .sql("{orders.user_id} = {users.id}".to_string()) -/// .build() -/// ); -/// -/// let edge = JoinEdge { -/// join: join_def, -/// from: "orders".to_string(), -/// to: "users".to_string(), -/// original_from: "Orders".to_string(), -/// original_to: "Users".to_string(), -/// }; -/// -/// assert_eq!(edge.from, "orders"); -/// assert_eq!(edge.original_from, "Orders"); /// ``` #[derive(Debug, Clone)] pub struct JoinEdge { - /// The join definition containing the relationship and SQL pub join: Rc, - /// The current source cube name (may be an alias) pub from: String, - /// The current destination cube name (may be an alias) pub to: String, - /// The original source cube name (without aliases) pub original_from: String, - /// The original destination cube name (without aliases) pub original_to: String, } @@ -63,13 +35,6 @@ pub struct JoinEdge { /// pathfinding and connectivity queries. It also caches built join trees to avoid /// redundant computation. /// -/// # Example -/// -/// ``` -/// use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; -/// -/// let graph = MockJoinGraph::new(); -/// // Add edges and build joins... /// ``` #[derive(Clone)] pub struct MockJoinGraph { @@ -97,15 +62,6 @@ pub struct MockJoinGraph { } impl MockJoinGraph { - /// Creates a new empty join graph - /// - /// # Example - /// - /// ``` - /// use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; - /// - /// let graph = MockJoinGraph::new(); - /// ``` pub fn new() -> Self { Self { nodes: HashMap::new(), @@ -116,36 +72,10 @@ impl MockJoinGraph { } } - /// Creates an edge key from source and destination cube names - /// - /// The key format is "from-to", matching the TypeScript implementation. - /// - /// # Arguments - /// - /// * `from` - Source cube name - /// * `to` - Destination cube name - /// - /// # Example - /// - /// ``` - /// # use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; - /// let key = MockJoinGraph::edge_key("orders", "users"); - /// assert_eq!(key, "orders-users"); - /// ``` pub(crate) fn edge_key(from: &str, to: &str) -> String { format!("{}-{}", from, to) } - /// Builds join edges for a single cube - /// - /// This method extracts all joins from the cube, validates them, and creates JoinEdge instances. - /// - /// # Validation - /// - Target cube must exist - /// - Source and target cubes with multiplied measures must have primary keys - /// - /// # Returns - /// Vector of (edge_key, JoinEdge) tuples fn build_join_edges( &self, cube: &crate::test_fixtures::cube_bridge::MockCubeDefinition, @@ -207,9 +137,6 @@ impl MockJoinGraph { Ok(result) } - /// Gets measures that are "multiplied" by joins (require primary keys) - /// - /// Multiplied measure types: sum, avg, count, number fn get_multiplied_measures( &self, cube_name: &str, @@ -229,29 +156,6 @@ impl MockJoinGraph { Ok(result) } - /// Extracts the cube name from a JoinHintItem - /// - /// For Single variants, returns the cube name directly. - /// For Vector variants, returns the last element (the destination). - /// - /// # Arguments - /// * `cube_path` - The JoinHintItem to extract from - /// - /// # Returns - /// The cube name as a String - /// - /// # Example - /// ``` - /// use cubesqlplanner::cube_bridge::join_hints::JoinHintItem; - /// # use cubesqlplanner::test_fixtures::cube_bridge::MockJoinGraph; - /// # let graph = MockJoinGraph::new(); - /// - /// let single = JoinHintItem::Single("users".to_string()); - /// assert_eq!(graph.cube_from_path(&single), "users"); - /// - /// let vector = JoinHintItem::Vector(vec!["orders".to_string(), "users".to_string()]); - /// assert_eq!(graph.cube_from_path(&vector), "users"); - /// ``` fn cube_from_path(&self, cube_path: &JoinHintItem) -> String { match cube_path { JoinHintItem::Single(name) => name.clone(), @@ -262,23 +166,6 @@ impl MockJoinGraph { } } - /// Converts a path of cube names to a list of JoinEdges - /// - /// For a path [A, B, C], this looks up edges "A-B" and "B-C" in the edges HashMap. - /// - /// # Arguments - /// * `path` - Slice of cube names representing the path - /// - /// # Returns - /// Vector of JoinEdge instances corresponding to consecutive pairs in the path - /// - /// # Example - /// ```ignore - /// // For path ["orders", "users", "countries"] - /// // Returns edges for "orders-users" and "users-countries" - /// let path = vec!["orders".to_string(), "users".to_string(), "countries".to_string()]; - /// let joins = graph.joins_by_path(&path); - /// ``` fn joins_by_path(&self, path: &[String]) -> Vec { let mut result = Vec::new(); for i in 0..path.len().saturating_sub(1) { @@ -290,28 +177,6 @@ impl MockJoinGraph { result } - /// Builds a join tree with a specific root cube - /// - /// This method tries to build a join tree starting from the specified root, - /// connecting to all cubes in cubes_to_join. It uses Dijkstra's algorithm - /// to find the shortest paths. - /// - /// # Arguments - /// * `root` - The root cube (can be Single or Vector) - /// * `cubes_to_join` - Other cubes to connect to the root - /// - /// # Returns - /// * `Some((root_name, joins))` - If a valid join tree can be built - /// * `None` - If no path exists to connect all cubes - /// - /// # Algorithm - /// 1. Extract root name (if Vector, first element becomes root, rest go to cubes_to_join) - /// 2. Track joined nodes to avoid duplicates - /// 3. For each cube to join: - /// - Find shortest path from previous node - /// - Convert path to JoinEdge list - /// - Mark nodes as joined - /// 4. Collect and deduplicate all joins fn build_join_tree_for_root( &self, root: &JoinHintItem, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs deleted file mode 100644 index 9288a9cea0209..0000000000000 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph_tests.rs +++ /dev/null @@ -1,1533 +0,0 @@ -use crate::cube_bridge::evaluator::CubeEvaluator; -use crate::cube_bridge::join_definition::JoinDefinition; -use crate::cube_bridge::join_hints::JoinHintItem; -use crate::test_fixtures::cube_bridge::{ - JoinEdge, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockJoinGraph, - MockJoinItemDefinition, MockMeasureDefinition, MockSchemaBuilder, -}; -use cubenativeutils::CubeError; -use std::collections::HashMap; -use std::rc::Rc; - -/// Creates comprehensive test schema covering all join graph test scenarios -/// -/// This schema includes multiple independent subgraphs designed to test different -/// join patterns and relationships: -/// -/// 1. **Simple Join (orders -> users)** -/// - For: simple join tests, basic compilation -/// - Cubes: orders, users -/// - Join: orders many_to_one users -/// -/// 2. **Chain (products -> categories -> departments)** -/// - For: chain tests, transitive multiplication -/// - Cubes: products, categories, departments -/// - Joins: products -> categories -> departments -/// -/// 3. **Star Pattern (accounts -> [contacts, deals, tasks])** -/// - For: star pattern tests -/// - Cubes: accounts, contacts, deals, tasks -/// - Joins: accounts -> contacts, accounts -> deals, accounts -> tasks -/// -/// 4. **Relationship Variations (companies <-> employees)** -/// - For: hasMany, belongsTo, bidirectional tests -/// - Cubes: companies, employees, projects -/// - Joins: companies hasMany employees, employees belongsTo companies, employees many_to_one projects -/// -/// 5. **Cycle (regions -> countries -> cities -> regions)** -/// - For: cycle detection tests -/// - Cubes: regions, countries, cities -/// - Joins: regions -> countries -> cities -> regions (back to regions) -/// -/// 6. **Disconnected (warehouses, suppliers - no joins between them)** -/// - For: disconnected component tests -/// - Cubes: warehouses, suppliers (isolated) -/// -/// 7. **Validation Scenarios** -/// - orders_with_measures: has measures, has primary key -/// - orders_without_pk: has measures, NO primary key (for error tests) -/// -/// # Returns -/// -/// Tuple of (evaluator, cubes_map) where cubes_map is a HashMap -/// allowing easy access to cubes by name for test setup. -fn create_comprehensive_test_schema() -> ( - Rc, - HashMap>, -) { - let schema = MockSchemaBuilder::new() - // === 1. SIMPLE JOIN: orders -> users === - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - // === 2. CHAIN: products -> categories -> departments === - .add_cube("products") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "categories", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.category_id = {categories.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("categories") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "departments", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.department_id = {departments.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("departments") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - // === 3. STAR: accounts -> [contacts, deals, tasks] === - .add_cube("accounts") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "contacts", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {contacts.account_id}".to_string()) - .build(), - ) - .add_join( - "deals", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {deals.account_id}".to_string()) - .build(), - ) - .add_join( - "tasks", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {tasks.account_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("contacts") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .finish_cube() - .add_cube("deals") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .finish_cube() - .add_cube("tasks") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .finish_cube() - // === 4. BIDIRECTIONAL: companies <-> employees -> projects === - .add_cube("companies") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "employees", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {employees.company_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("employees") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "companies", - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{CUBE}.company_id = {companies.id}".to_string()) - .build(), - ) - .add_join( - "projects", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.project_id = {projects.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("projects") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - // === 5. CYCLE: regions -> countries -> cities -> regions === - .add_cube("regions") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "countries", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {countries.region_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("countries") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "cities", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {cities.country_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("cities") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "regions", - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{CUBE}.region_id = {regions.id}".to_string()) - .build(), - ) - .finish_cube() - // === 6. DISCONNECTED: warehouses, suppliers (isolated) === - .add_cube("warehouses") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .add_cube("suppliers") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - // === 7. VALIDATION: orders_with_measures (has PK), orders_without_pk (no PK) === - .add_cube("orders_with_measures") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .finish_cube() - .add_cube("orders_without_pk") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - // NO primary_key set - .build(), - ) - .add_measure( - "count", - MockMeasureDefinition::builder() - .measure_type("count".to_string()) - .sql("COUNT(*)".to_string()) - .build(), - ) - .add_join( - "users", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{orders_without_pk}.user_id = {users.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - - // Build cubes map for easy access by name - let cube_names = vec![ - "orders", - "users", - "products", - "categories", - "departments", - "accounts", - "contacts", - "deals", - "tasks", - "companies", - "employees", - "projects", - "regions", - "countries", - "cities", - "warehouses", - "suppliers", - "orders_with_measures", - "orders_without_pk", - ]; - - let mut cubes_map = HashMap::new(); - for name in cube_names { - let cube = evaluator - .cube_from_path(name.to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(); - cubes_map.insert(name.to_string(), Rc::new(cube)); - } - - (evaluator, cubes_map) -} - -/// Extracts a subset of cubes from the cubes map by name -/// -/// # Arguments -/// * `cubes_map` - HashMap of all available cubes -/// * `cube_names` - Names of cubes to extract -/// -/// # Returns -/// Vec of cubes in the order specified by cube_names -fn get_cubes_vec( - cubes_map: &HashMap>, - cube_names: &[&str], -) -> Vec> { - cube_names - .iter() - .map(|name| cubes_map.get(*name).unwrap().clone()) - .collect() -} - -/// Creates and compiles a join graph from specified cubes -/// -/// # Arguments -/// * `cubes_map` - HashMap of all available cubes -/// * `cube_names` - Names of cubes to include in graph -/// * `evaluator` - Cube evaluator -/// -/// # Returns -/// Compiled MockJoinGraph -fn compile_test_graph( - cubes_map: &HashMap>, - cube_names: &[&str], - evaluator: &Rc, -) -> Result { - let cubes = get_cubes_vec(cubes_map, cube_names); - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, evaluator)?; - Ok(graph) -} - -#[test] -fn test_mock_join_graph_new() { - let graph = MockJoinGraph::new(); - - // Verify all fields are empty - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); - assert!(graph.edges.is_empty()); - assert!(graph.built_joins.borrow().is_empty()); - assert!(graph.cached_connected_components.is_none()); -} - -#[test] -fn test_edge_key_format() { - let key = MockJoinGraph::edge_key("orders", "users"); - assert_eq!(key, "orders-users"); - - let key2 = MockJoinGraph::edge_key("users", "countries"); - assert_eq!(key2, "users-countries"); - - // Verify different order creates different key - let key3 = MockJoinGraph::edge_key("users", "orders"); - assert_ne!(key, key3); -} - -#[test] -fn test_join_edge_creation() { - let join_def = Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ); - - let edge = JoinEdge { - join: join_def.clone(), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "Orders".to_string(), - original_to: "Users".to_string(), - }; - - assert_eq!(edge.from, "orders"); - assert_eq!(edge.to, "users"); - assert_eq!(edge.original_from, "Orders"); - assert_eq!(edge.original_to, "Users"); - assert_eq!(edge.join.static_data().relationship, "many_to_one"); -} - -#[test] -fn test_default_trait() { - let graph = MockJoinGraph::default(); - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); -} - -#[test] -fn test_clone_trait() { - let graph = MockJoinGraph::new(); - let cloned = graph.clone(); - - assert!(cloned.nodes.is_empty()); - assert!(cloned.undirected_nodes.is_empty()); -} - -#[test] -fn test_compile_simple_graph() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); - - // Verify edges contains "orders-users" - assert!(graph.edges.contains_key("orders-users")); - assert_eq!(graph.edges.len(), 1); - - // Verify nodes: both cubes present, "orders" has edge to "users" - assert_eq!(graph.nodes.len(), 2); - assert!(graph.nodes.contains_key("orders")); - assert!(graph.nodes.contains_key("users")); - let orders_destinations = graph.nodes.get("orders").unwrap(); - assert_eq!(orders_destinations.get("users"), Some(&1)); - - // Verify undirected_nodes: {"users": {"orders": 1}} - assert_eq!(graph.undirected_nodes.len(), 1); - assert!(graph.undirected_nodes.contains_key("users")); - let users_connections = graph.undirected_nodes.get("users").unwrap(); - assert_eq!(users_connections.get("orders"), Some(&1)); -} - -#[test] -fn test_compile_multiple_joins() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - // Use accounts star pattern: accounts -> contacts, accounts -> deals, accounts -> tasks - let graph = compile_test_graph( - &cubes_map, - &["accounts", "contacts", "deals", "tasks"], - &evaluator, - ) - .unwrap(); - - // Verify all edges present - assert_eq!(graph.edges.len(), 3); - assert!(graph.edges.contains_key("accounts-contacts")); - assert!(graph.edges.contains_key("accounts-deals")); - assert!(graph.edges.contains_key("accounts-tasks")); - - // Verify nodes correctly structured - all 4 cubes should be present - assert_eq!(graph.nodes.len(), 4); - assert!(graph.nodes.contains_key("accounts")); - assert!(graph.nodes.contains_key("contacts")); - assert!(graph.nodes.contains_key("deals")); - assert!(graph.nodes.contains_key("tasks")); - - let accounts_dests = graph.nodes.get("accounts").unwrap(); - assert_eq!(accounts_dests.len(), 3); - assert_eq!(accounts_dests.get("contacts"), Some(&1)); - assert_eq!(accounts_dests.get("deals"), Some(&1)); - assert_eq!(accounts_dests.get("tasks"), Some(&1)); -} - -#[test] -fn test_compile_bidirectional() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - // Use companies <-> employees bidirectional relationship - // Note: employees also joins to projects, so include it in compilation - let graph = compile_test_graph( - &cubes_map, - &["companies", "employees", "projects"], - &evaluator, - ) - .unwrap(); - - // Verify both bidirectional edges exist - assert!(graph.edges.contains_key("companies-employees")); - assert!(graph.edges.contains_key("employees-companies")); - - // Also verify the employees -> projects edge - assert!(graph.edges.contains_key("employees-projects")); - assert_eq!(graph.edges.len(), 3); - - // Verify undirected_nodes includes all three cubes - assert_eq!(graph.undirected_nodes.len(), 3); - assert!(graph.undirected_nodes.contains_key("companies")); - assert!(graph.undirected_nodes.contains_key("employees")); - assert!(graph.undirected_nodes.contains_key("projects")); -} - -#[test] -fn test_compile_nonexistent_cube() { - // Create cube A with join to nonexistent B - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - )]; - - let mut graph = MockJoinGraph::new(); - let result = graph.compile(&cubes, &evaluator); - - // Compile should return error - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Cube B doesn't exist")); -} - -#[test] -fn test_compile_missing_primary_key() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - // orders_without_pk has measures and hasMany join but no primary key - let cubes = get_cubes_vec(&cubes_map, &["orders_without_pk", "users"]); - - let mut graph = MockJoinGraph::new(); - let result = graph.compile(&cubes, &evaluator); - - // Compile should return error about missing primary key - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .message - .contains("primary key for 'orders_without_pk' is required")); -} - -#[test] -fn test_compile_with_primary_key() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - // orders_with_measures has measure with primary key - should compile successfully - let graph = compile_test_graph(&cubes_map, &["orders_with_measures"], &evaluator).unwrap(); - - // Compile should succeed with cube that has measures and primary key - // Single cube with no joins means no edges - assert!(graph.edges.is_empty()); -} - -#[test] -fn test_recompile_clears_state() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - // First compile with orders -> users - let cubes1 = get_cubes_vec(&cubes_map, &["orders", "users"]); - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes1, &evaluator).unwrap(); - assert_eq!(graph.edges.len(), 1); - assert!(graph.edges.contains_key("orders-users")); - - // Recompile with products -> categories -> departments - let cubes2 = get_cubes_vec(&cubes_map, &["products", "categories", "departments"]); - graph.compile(&cubes2, &evaluator).unwrap(); - - // Verify old edges gone - assert!(!graph.edges.contains_key("orders-users")); - - // Verify only new edges present - assert_eq!(graph.edges.len(), 2); - assert!(graph.edges.contains_key("products-categories")); - assert!(graph.edges.contains_key("categories-departments")); -} - -#[test] -fn test_compile_empty() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - let graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); - - // Verify all HashMaps empty - assert!(graph.edges.is_empty()); - assert!(graph.nodes.is_empty()); - assert!(graph.undirected_nodes.is_empty()); - assert!(graph.built_joins.borrow().is_empty()); -} - -// Tests for build_join functionality - -#[test] -fn test_build_join_simple() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); - - // Build join: orders -> users - let cubes_to_join = vec![ - JoinHintItem::Single("orders".to_string()), - JoinHintItem::Single("users".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=orders, joins=[orders->users], no multiplication - assert_eq!(result.static_data().root, "orders"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 1); - - let join_static = joins[0].static_data(); - assert_eq!(join_static.from, "orders"); - assert_eq!(join_static.to, "users"); - - // Check multiplication factors - let mult_factors = result.static_data().multiplication_factor.clone(); - assert_eq!(mult_factors.get("orders"), Some(&false)); - assert_eq!(mult_factors.get("users"), Some(&false)); -} - -#[test] -fn test_build_join_chain() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - - let graph = compile_test_graph( - &cubes_map, - &["products", "categories", "departments"], - &evaluator, - ) - .unwrap(); - - // Build join: products -> categories -> departments - let cubes_to_join = vec![ - JoinHintItem::Single("products".to_string()), - JoinHintItem::Single("categories".to_string()), - JoinHintItem::Single("departments".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=products, joins=[products->categories, categories->departments] - assert_eq!(result.static_data().root, "products"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 2); - - assert_eq!(joins[0].static_data().from, "products"); - assert_eq!(joins[0].static_data().to, "categories"); - assert_eq!(joins[1].static_data().from, "categories"); - assert_eq!(joins[1].static_data().to, "departments"); -} - -#[test] -fn test_build_join_shortest_path() { - // Schema: A -> B -> C (2 hops) - // A -> C (1 hop - shortest) - let schema = MockSchemaBuilder::new() - .add_cube("A") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "B", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.b_id = {B.id}".to_string()) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("B") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "C", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.c_id = {C.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("C") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("A".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("B".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("C".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: A, C - let cubes_to_join = vec![ - JoinHintItem::Single("A".to_string()), - JoinHintItem::Single("C".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: use direct path A->C (not A->B->C) - assert_eq!(result.static_data().root, "A"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 1); - - assert_eq!(joins[0].static_data().from, "A"); - assert_eq!(joins[0].static_data().to, "C"); -} - -#[test] -fn test_build_join_star_pattern() { - // Schema: accounts -> contacts, accounts -> deals, accounts -> tasks - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph( - &cubes_map, - &["accounts", "contacts", "deals", "tasks"], - &evaluator, - ) - .unwrap(); - - // Build join: accounts, contacts, deals, tasks - let cubes_to_join = vec![ - JoinHintItem::Single("accounts".to_string()), - JoinHintItem::Single("contacts".to_string()), - JoinHintItem::Single("deals".to_string()), - JoinHintItem::Single("tasks".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=accounts, joins to all others - assert_eq!(result.static_data().root, "accounts"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 3); - - // All joins should be from accounts - assert_eq!(joins[0].static_data().from, "accounts"); - assert_eq!(joins[1].static_data().from, "accounts"); - assert_eq!(joins[2].static_data().from, "accounts"); -} - -#[test] -fn test_build_join_disconnected() { - // Schema: warehouses and suppliers are disconnected (no join) - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph(&cubes_map, &["warehouses", "suppliers"], &evaluator).unwrap(); - - // Build join: warehouses, suppliers (disconnected) - let cubes_to_join = vec![ - JoinHintItem::Single("warehouses".to_string()), - JoinHintItem::Single("suppliers".to_string()), - ]; - let result = graph.build_join(cubes_to_join); - - // Expected: error "Can't find join path" - assert!(result.is_err()); - let err_msg = result.unwrap_err().message; - assert!(err_msg.contains("Can't find join path")); - assert!(err_msg.contains("'warehouses'")); - assert!(err_msg.contains("'suppliers'")); -} - -#[test] -fn test_build_join_empty() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); - - // Build join with empty list - let cubes_to_join = vec![]; - let result = graph.build_join(cubes_to_join); - - // Expected: error - assert!(result.is_err()); - let err_msg = result.unwrap_err().message; - assert!(err_msg.contains("empty")); -} - -#[test] -fn test_build_join_single_cube() { - // Schema: orders (single cube) - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph(&cubes_map, &["orders"], &evaluator).unwrap(); - - // Build join with single cube - let cubes_to_join = vec![JoinHintItem::Single("orders".to_string())]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=orders, no joins - assert_eq!(result.static_data().root, "orders"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 0); -} - -#[test] -fn test_build_join_caching() { - // Schema: orders -> users - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); - - // Build join twice - let cubes_to_join = vec![ - JoinHintItem::Single("orders".to_string()), - JoinHintItem::Single("users".to_string()), - ]; - let result1 = graph.build_join(cubes_to_join.clone()).unwrap(); - let result2 = graph.build_join(cubes_to_join).unwrap(); - - // Verify same Rc returned (pointer equality) - assert!(Rc::ptr_eq(&result1, &result2)); -} - -#[test] -fn test_multiplication_factor_has_many() { - // users hasMany orders - // users should multiply, orders should not - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); -} - -#[test] -fn test_multiplication_factor_belongs_to() { - // orders belongsTo users - // users should multiply, orders should not - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); -} - -#[test] -fn test_multiplication_factor_transitive() { - // users hasMany orders, orders hasMany items - // users multiplies (direct hasMany) - // orders multiplies (has hasMany to items) - // items does not multiply - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {items.order_id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "items".to_string(), - original_from: "orders".to_string(), - original_to: "items".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - assert!(graph.find_multiplication_factor_for("orders", &joins)); - assert!(!graph.find_multiplication_factor_for("items", &joins)); -} - -#[test] -fn test_multiplication_factor_many_to_one() { - // orders many_to_one users (neither multiplies) - let graph = MockJoinGraph::new(); - - let joins = vec![JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.user_id = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }]; - - assert!(!graph.find_multiplication_factor_for("users", &joins)); - assert!(!graph.find_multiplication_factor_for("orders", &joins)); -} - -#[test] -fn test_multiplication_factor_star_pattern() { - // users hasMany orders, users hasMany sessions - // In this graph topology: - // - users multiplies (has hasMany to unvisited nodes) - // - orders multiplies (connected to users which has hasMany to sessions) - // - sessions multiplies (connected to users which has hasMany to orders) - // This is because the algorithm checks for multiplication in the connected component - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {sessions.user_id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "sessions".to_string(), - original_from: "users".to_string(), - original_to: "sessions".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("users", &joins)); - // orders and sessions both return true because users (connected node) has hasMany - assert!(graph.find_multiplication_factor_for("orders", &joins)); - assert!(graph.find_multiplication_factor_for("sessions", &joins)); -} - -#[test] -fn test_multiplication_factor_cycle() { - // A hasMany B, B hasMany A (cycle) - // Both should multiply - let graph = MockJoinGraph::new(); - - let joins = vec![ - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {B.a_id}".to_string()) - .build(), - ), - from: "A".to_string(), - to: "B".to_string(), - original_from: "A".to_string(), - original_to: "B".to_string(), - }, - JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {A.b_id}".to_string()) - .build(), - ), - from: "B".to_string(), - to: "A".to_string(), - original_from: "B".to_string(), - original_to: "A".to_string(), - }, - ]; - - assert!(graph.find_multiplication_factor_for("A", &joins)); - assert!(graph.find_multiplication_factor_for("B", &joins)); -} - -#[test] -fn test_build_join_with_multiplication_factors() { - // Schema: users hasMany orders, orders many_to_one products - let schema = MockSchemaBuilder::new() - .add_cube("users") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "orders", - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{CUBE}.id = {orders.user_id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("orders") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .add_join( - "products", - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{CUBE}.product_id = {products.id}".to_string()) - .build(), - ) - .finish_cube() - .add_cube("products") - .add_dimension( - "id", - MockDimensionDefinition::builder() - .dimension_type("number".to_string()) - .sql("id".to_string()) - .primary_key(Some(true)) - .build(), - ) - .finish_cube() - .build(); - - let evaluator = schema.create_evaluator(); - let cubes: Vec> = vec![ - Rc::new( - evaluator - .cube_from_path("users".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("orders".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - Rc::new( - evaluator - .cube_from_path("products".to_string()) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .clone(), - ), - ]; - - let mut graph = MockJoinGraph::new(); - graph.compile(&cubes, &evaluator).unwrap(); - - // Build join: users -> orders -> products - let cubes_to_join = vec![ - JoinHintItem::Single("users".to_string()), - JoinHintItem::Single("orders".to_string()), - JoinHintItem::Single("products".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Check multiplication factors - let mult_factors = result.static_data().multiplication_factor.clone(); - - // users hasMany orders -> users multiplies - assert_eq!(mult_factors.get("users"), Some(&true)); - - // orders is in the middle, does not have its own hasMany, does not multiply - assert_eq!(mult_factors.get("orders"), Some(&false)); - - // products is leaf with many_to_one, does not multiply - assert_eq!(mult_factors.get("products"), Some(&false)); -} - -#[test] -fn test_check_if_cube_multiplied() { - let graph = MockJoinGraph::new(); - - // hasMany: from side multiplies - let join_has_many = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("hasMany".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "users".to_string(), - to: "orders".to_string(), - original_from: "users".to_string(), - original_to: "orders".to_string(), - }; - - assert!(graph.check_if_cube_multiplied("users", &join_has_many)); - assert!(!graph.check_if_cube_multiplied("orders", &join_has_many)); - - // belongsTo: to side multiplies - let join_belongs_to = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("belongsTo".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }; - - assert!(graph.check_if_cube_multiplied("users", &join_belongs_to)); - assert!(!graph.check_if_cube_multiplied("orders", &join_belongs_to)); - - // many_to_one: no multiplication - let join_many_to_one = JoinEdge { - join: Rc::new( - MockJoinItemDefinition::builder() - .relationship("many_to_one".to_string()) - .sql("{orders.user_id} = {users.id}".to_string()) - .build(), - ), - from: "orders".to_string(), - to: "users".to_string(), - original_from: "orders".to_string(), - original_to: "users".to_string(), - }; - - assert!(!graph.check_if_cube_multiplied("users", &join_many_to_one)); - assert!(!graph.check_if_cube_multiplied("orders", &join_many_to_one)); -} - -#[test] -fn test_build_join_with_vector_hint() { - // Schema: products -> categories -> departments - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let graph = compile_test_graph( - &cubes_map, - &["products", "categories", "departments"], - &evaluator, - ) - .unwrap(); - - // Build join with Vector hint: [products, categories] becomes root=products, join to categories, then join to departments - let cubes_to_join = vec![ - JoinHintItem::Vector(vec!["products".to_string(), "categories".to_string()]), - JoinHintItem::Single("departments".to_string()), - ]; - let result = graph.build_join(cubes_to_join).unwrap(); - - // Expected: root=products, joins=[products->categories, categories->departments] - assert_eq!(result.static_data().root, "products"); - let joins = result.joins().unwrap(); - assert_eq!(joins.len(), 2); - - assert_eq!(joins[0].static_data().from, "products"); - assert_eq!(joins[0].static_data().to, "categories"); - assert_eq!(joins[1].static_data().from, "categories"); - assert_eq!(joins[1].static_data().to, "departments"); -} - -#[test] -fn test_connected_components_simple() { - // Graph: orders -> users (both in same component) - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); - - let components = graph.connected_components(); - - // Both cubes should be in same component - assert_eq!(components.len(), 2); - let orders_comp = components.get("orders").unwrap(); - let users_comp = components.get("users").unwrap(); - assert_eq!(orders_comp, users_comp); -} - -#[test] -fn test_connected_components_disconnected() { - // Graph: orders -> users (connected), warehouses, suppliers (both isolated) - // Three components: {orders, users}, {warehouses}, {suppliers} - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph( - &cubes_map, - &["orders", "users", "warehouses", "suppliers"], - &evaluator, - ) - .unwrap(); - - let components = graph.connected_components(); - - // All four cubes should have component IDs - assert_eq!(components.len(), 4); - - // orders and users in same component - let orders_comp = components.get("orders").unwrap(); - let users_comp = components.get("users").unwrap(); - assert_eq!(orders_comp, users_comp); - - // warehouses and suppliers in different components - let warehouses_comp = components.get("warehouses").unwrap(); - let suppliers_comp = components.get("suppliers").unwrap(); - assert_ne!(orders_comp, warehouses_comp); - assert_ne!(orders_comp, suppliers_comp); - assert_ne!(warehouses_comp, suppliers_comp); -} - -#[test] -fn test_connected_components_all_isolated() { - // Graph: warehouses, suppliers, orders_with_measures (no joins) - // Three components: {warehouses}, {suppliers}, {orders_with_measures} - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph( - &cubes_map, - &["warehouses", "suppliers", "orders_with_measures"], - &evaluator, - ) - .unwrap(); - - let components = graph.connected_components(); - - // All three cubes in different components - assert_eq!(components.len(), 3); - let warehouses_comp = components.get("warehouses").unwrap(); - let suppliers_comp = components.get("suppliers").unwrap(); - let orders_with_measures_comp = components.get("orders_with_measures").unwrap(); - assert_ne!(warehouses_comp, suppliers_comp); - assert_ne!(suppliers_comp, orders_with_measures_comp); - assert_ne!(warehouses_comp, orders_with_measures_comp); -} - -#[test] -fn test_connected_components_large_connected() { - // Chain: products -> categories -> departments (all in same component) - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph( - &cubes_map, - &["products", "categories", "departments"], - &evaluator, - ) - .unwrap(); - - let components = graph.connected_components(); - - // All cubes in same component - assert_eq!(components.len(), 3); - let products_comp = components.get("products").unwrap(); - let categories_comp = components.get("categories").unwrap(); - let departments_comp = components.get("departments").unwrap(); - - assert_eq!(products_comp, categories_comp); - assert_eq!(categories_comp, departments_comp); -} - -#[test] -fn test_connected_components_cycle() { - // Cycle: regions -> countries -> cities -> regions - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = - compile_test_graph(&cubes_map, &["regions", "countries", "cities"], &evaluator).unwrap(); - - let components = graph.connected_components(); - - // All three in same component (cycle) - assert_eq!(components.len(), 3); - let regions_comp = components.get("regions").unwrap(); - let countries_comp = components.get("countries").unwrap(); - let cities_comp = components.get("cities").unwrap(); - - assert_eq!(regions_comp, countries_comp); - assert_eq!(countries_comp, cities_comp); -} - -#[test] -fn test_connected_components_empty() { - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph(&cubes_map, &[], &evaluator).unwrap(); - - let components = graph.connected_components(); - - // Empty graph - assert_eq!(components.len(), 0); -} - -#[test] -fn test_connected_components_caching() { - // Verify components are cached - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph(&cubes_map, &["orders", "users"], &evaluator).unwrap(); - - // First call calculates - let components1 = graph.connected_components(); - - // Second call should return cached result - let components2 = graph.connected_components(); - - assert_eq!(components1, components2); -} - -#[test] -fn test_connected_components_multiple_groups() { - // Three disconnected groups: - // - orders -> users - // - warehouses - // - suppliers - let (evaluator, cubes_map) = create_comprehensive_test_schema(); - let mut graph = compile_test_graph( - &cubes_map, - &["orders", "users", "warehouses", "suppliers"], - &evaluator, - ) - .unwrap(); - - let components = graph.connected_components(); - - assert_eq!(components.len(), 4); - - let orders_comp = components.get("orders").unwrap(); - let users_comp = components.get("users").unwrap(); - let warehouses_comp = components.get("warehouses").unwrap(); - let suppliers_comp = components.get("suppliers").unwrap(); - - // orders and users connected - assert_eq!(orders_comp, users_comp); - - // Others disconnected - assert_ne!(orders_comp, warehouses_comp); - assert_ne!(orders_comp, suppliers_comp); - assert_ne!(warehouses_comp, suppliers_comp); -} 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 4c05e3fc9fcb9..c7e3c3ad99907 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -31,9 +31,6 @@ mod mock_sql_utils; mod mock_struct_with_sql_member; mod mock_timeshift_definition; -#[cfg(test)] -mod mock_join_graph_tests; - pub use mock_base_tools::MockBaseTools; pub use mock_case_definition::MockCaseDefinition; pub use mock_case_else_item::MockCaseElseItem; From 66f85794957762a979aca6f48fa9dab0ba2052f8 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 17 Nov 2025 13:39:03 +0100 Subject: [PATCH 12/13] lint --- .../cube_bridge/mock_driver_tools.rs | 1 + .../cube_bridge/mock_evaluator.rs | 1 + .../cube_bridge/mock_join_graph.rs | 191 +----------------- .../src/test_fixtures/cube_bridge/mod.rs | 3 +- 4 files changed, 5 insertions(+), 191 deletions(-) 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 ac433277748a4..23a7693e1b332 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 @@ -26,6 +26,7 @@ impl MockDriverTools { } } + #[allow(dead_code)] pub fn with_timezone(timezone: String) -> Self { Self { timezone, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs index fe2180b790d90..5ff1644f6a15d 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_evaluator.rs @@ -21,6 +21,7 @@ pub struct MockCubeEvaluator { } impl MockCubeEvaluator { + #[allow(dead_code)] pub fn new(schema: MockSchema) -> Self { Self { schema, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs index fc41cc59a5eea..46101d6ca314c 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_join_graph.rs @@ -90,12 +90,10 @@ impl MockJoinGraph { let cube_name = &cube.static_data().name; for (join_name, join_def) in joins { - // Validate target cube exists if !evaluator.cube_exists(join_name.clone())? { return Err(CubeError::user(format!("Cube {} doesn't exist", join_name))); } - // Check multiplied measures for source cube let from_multiplied = self.get_multiplied_measures(cube_name, evaluator)?; if !from_multiplied.is_empty() { let static_data = evaluator.static_data(); @@ -108,7 +106,6 @@ impl MockJoinGraph { } } - // Check multiplied measures for target cube let to_multiplied = self.get_multiplied_measures(join_name, evaluator)?; if !to_multiplied.is_empty() { let static_data = evaluator.static_data(); @@ -121,7 +118,6 @@ impl MockJoinGraph { } } - // Create JoinEdge let edge = JoinEdge { join: Rc::new(join_def.clone()), from: cube_name.clone(), @@ -185,7 +181,6 @@ impl MockJoinGraph { use crate::test_fixtures::graph_utils::find_shortest_path; use std::collections::HashSet; - // Extract root and additional cubes to join let (root_name, additional_cubes) = match root { JoinHintItem::Single(name) => (name.clone(), Vec::new()), JoinHintItem::Vector(path) => { @@ -202,30 +197,23 @@ impl MockJoinGraph { } }; - // Combine additional cubes with cubes_to_join let mut all_cubes_to_join = additional_cubes; all_cubes_to_join.extend_from_slice(cubes_to_join); - // Track which nodes have been joined let mut nodes_joined: HashSet = HashSet::new(); - // Collect all joins with their indices let mut all_joins: Vec<(usize, JoinEdge)> = Vec::new(); let mut next_index = 0; - // Process each cube to join for join_hint in &all_cubes_to_join { - // Convert to Vector if Single let path_elements = match join_hint { JoinHintItem::Single(name) => vec![name.clone()], JoinHintItem::Vector(path) => path.clone(), }; - // Find path from previous node to each target let mut prev_node = root_name.clone(); for to_join in &path_elements { - // Skip if already joined or same as previous if to_join == &prev_node { continue; } @@ -235,31 +223,25 @@ impl MockJoinGraph { continue; } - // Find shortest path let path = find_shortest_path(&self.nodes, &prev_node, to_join); path.as_ref()?; let path = path.unwrap(); - // Convert path to joins let found_joins = self.joins_by_path(&path); - // Add joins with indices for join in found_joins { all_joins.push((next_index, join)); next_index += 1; } - // Mark as joined nodes_joined.insert(to_join.clone()); prev_node = to_join.clone(); } } - // Sort by index and remove duplicates all_joins.sort_by_key(|(idx, _)| *idx); - // Remove duplicates by edge key let mut seen_keys: HashSet = HashSet::new(); let mut unique_joins: Vec = Vec::new(); @@ -274,49 +256,16 @@ impl MockJoinGraph { Some((root_name, unique_joins)) } - /// Builds a join definition from a list of cubes to join - /// - /// This is the main entry point for finding optimal join paths between cubes. - /// It tries each cube as a potential root and selects the shortest join tree. - /// - /// # Arguments - /// * `cubes_to_join` - Vector of JoinHintItem specifying which cubes to join - /// - /// # Returns - /// * `Ok(Rc)` - The optimal join definition with multiplication factors - /// * `Err(CubeError)` - If no join path exists or input is empty - /// - /// # Caching - /// Results are cached based on the serialized cubes_to_join. - /// Subsequent calls with the same cubes return the cached result. - /// - /// # Algorithm - /// 1. Check cache for existing result - /// 2. Try each cube as root, find shortest tree - /// 3. Calculate multiplication factors for each cube - /// 4. Create MockJoinDefinition with results - /// 5. Cache and return - /// - /// # Example - /// ```ignore - /// let cubes = vec![ - /// JoinHintItem::Single("orders".to_string()), - /// JoinHintItem::Single("users".to_string()), - /// ]; - /// let join_def = graph.build_join(cubes)?; - /// ``` pub fn build_join( &self, cubes_to_join: Vec, ) -> Result, CubeError> { - // Handle empty input if cubes_to_join.is_empty() { return Err(CubeError::user( "Cannot build join with empty cube list".to_string(), )); } - // Check cache let cache_key = serde_json::to_string(&cubes_to_join).map_err(|e| { CubeError::internal(format!("Failed to serialize cubes_to_join: {}", e)) })?; @@ -328,7 +277,6 @@ impl MockJoinGraph { } } - // Try each cube as root let mut join_trees: Vec<(String, Vec)> = Vec::new(); for i in 0..cubes_to_join.len() { @@ -342,10 +290,8 @@ impl MockJoinGraph { } } - // Sort by number of joins (shortest first) join_trees.sort_by_key(|(_, joins)| joins.len()); - // Take the shortest tree let (root_name, joins) = join_trees.first().ok_or_else(|| { let cube_names: Vec = cubes_to_join .iter() @@ -360,7 +306,6 @@ impl MockJoinGraph { )) })?; - // Calculate multiplication factors let mut multiplication_factor: HashMap = HashMap::new(); for cube_hint in &cubes_to_join { let cube_name = self.cube_from_path(cube_hint); @@ -368,13 +313,11 @@ impl MockJoinGraph { multiplication_factor.insert(cube_name, factor); } - // Convert JoinEdges to MockJoinItems let join_items: Vec> = joins .iter() .map(|edge| self.join_edge_to_mock_join_item(edge)) .collect(); - // Create MockJoinDefinition let join_def = Rc::new( MockJoinDefinition::builder() .root(root_name.clone()) @@ -383,7 +326,6 @@ impl MockJoinGraph { .build(), ); - // Cache and return self.built_joins .borrow_mut() .insert(cache_key, join_def.clone()); @@ -391,16 +333,6 @@ impl MockJoinGraph { Ok(join_def) } - /// Converts a JoinEdge to a MockJoinItem - /// - /// Helper method to convert internal JoinEdge representation to the MockJoinItem - /// type used in MockJoinDefinition. - /// - /// # Arguments - /// * `edge` - The JoinEdge to convert - /// - /// # Returns - /// Rc with the same from/to/original_from/original_to and join definition fn join_edge_to_mock_join_item( &self, edge: &JoinEdge, @@ -418,20 +350,6 @@ impl MockJoinGraph { ) } - /// Checks if a specific join causes row multiplication for a cube - /// - /// # Multiplication Rules - /// - If join.from == cube && relationship == "hasMany": multiplies - /// - If join.to == cube && relationship == "belongsTo": multiplies - /// - Otherwise: no multiplication - /// - /// # Arguments - /// * `cube` - The cube name to check - /// * `join` - The join edge to examine - /// - /// # Returns - /// * `true` if this join multiplies rows for the cube - /// * `false` otherwise pub(crate) fn check_if_cube_multiplied(&self, cube: &str, join: &JoinEdge) -> bool { let relationship = &join.join.static_data().relationship; @@ -439,33 +357,6 @@ impl MockJoinGraph { || (join.to == cube && relationship == "belongsTo") } - /// Determines if a cube has a multiplication factor in the join tree - /// - /// This method walks the join tree recursively to determine if joining - /// this cube causes row multiplication due to hasMany or belongsTo relationships. - /// - /// # Algorithm - /// 1. Start from the target cube - /// 2. Find all adjacent joins in the tree - /// 3. Check if any immediate join causes multiplication - /// 4. If not, recursively check adjacent cubes - /// 5. Use visited set to prevent infinite loops - /// - /// # Arguments - /// * `cube` - The cube name to check - /// * `joins` - The join edges in the tree - /// - /// # Returns - /// * `true` if this cube causes row multiplication - /// * `false` otherwise - /// - /// # Example - /// ```ignore - /// // users hasMany orders - /// let joins = vec![join_users_to_orders]; - /// assert!(graph.find_multiplication_factor_for("users", &joins)); - /// assert!(!graph.find_multiplication_factor_for("orders", &joins)); - /// ``` pub(crate) fn find_multiplication_factor_for(&self, cube: &str, joins: &[JoinEdge]) -> bool { use std::collections::HashSet; @@ -477,13 +368,11 @@ impl MockJoinGraph { joins: &[JoinEdge], visited: &mut HashSet, ) -> bool { - // Check if already visited (prevent cycles) if visited.contains(current_cube) { return false; } visited.insert(current_cube.to_string()); - // Helper to get next node in edge let next_node = |join: &JoinEdge| -> String { if join.from == current_cube { join.to.clone() @@ -492,13 +381,11 @@ impl MockJoinGraph { } }; - // Find all joins adjacent to current cube let next_joins: Vec<&JoinEdge> = joins .iter() .filter(|j| j.from == current_cube || j.to == current_cube) .collect(); - // Check if any immediate join multiplies AND leads to unvisited node if next_joins.iter().any(|next_join| { let next = next_node(next_join); graph.check_if_cube_multiplied(current_cube, next_join) && !visited.contains(&next) @@ -506,7 +393,6 @@ impl MockJoinGraph { return true; } - // Recursively check adjacent cubes next_joins.iter().any(|next_join| { let next = next_node(next_join); find_if_multiplied_recursive(graph, &next, joins, visited) @@ -516,38 +402,21 @@ impl MockJoinGraph { find_if_multiplied_recursive(self, cube, joins, &mut visited) } - /// Compiles the join graph from cube definitions - /// - /// This method processes all cubes and their join definitions to build the internal - /// graph structure needed for join path finding. It validates that: - /// - All referenced cubes exist - /// - Cubes with multiplied measures have primary keys defined - /// - /// # Arguments - /// * `cubes` - Slice of cube definitions to compile - /// * `evaluator` - Evaluator for validation and lookups - /// - /// # Returns - /// * `Ok(())` if compilation succeeds - /// * `Err(CubeError)` if validation fails pub fn compile( &mut self, cubes: &[Rc], evaluator: &crate::test_fixtures::cube_bridge::MockCubeEvaluator, ) -> Result<(), CubeError> { - // Clear existing state self.edges.clear(); self.nodes.clear(); self.undirected_nodes.clear(); self.cached_connected_components = None; - // First, ensure all cubes exist in nodes HashMap (even if they have no joins) for cube in cubes { let cube_name = cube.static_data().name.clone(); self.nodes.entry(cube_name).or_default(); } - // Build edges from all cubes for cube in cubes { let cube_edges = self.build_join_edges(cube, evaluator)?; for (key, edge) in cube_edges { @@ -555,8 +424,6 @@ impl MockJoinGraph { } } - // Build nodes HashMap (directed graph) - // Group edges by 'from' field and create HashMap of destinations for edge in self.edges.values() { self.nodes .entry(edge.from.clone()) @@ -564,8 +431,6 @@ impl MockJoinGraph { .insert(edge.to.clone(), 1); } - // Build undirected_nodes HashMap - // For each edge (from -> to), also add (to -> from) for bidirectional connectivity for edge in self.edges.values() { self.undirected_nodes .entry(edge.to.clone()) @@ -576,51 +441,25 @@ impl MockJoinGraph { Ok(()) } - /// Recursively marks all cubes in a connected component - /// - /// This method performs a depth-first search starting from the given node, - /// marking all reachable nodes with the same component ID. It uses the - /// undirected_nodes graph to traverse in both directions. - /// - /// # Algorithm - /// 1. Check if node already has a component ID (base case) - /// 2. Assign component ID to current node - /// 3. Find all connected nodes in undirected_nodes graph - /// 4. Recursively process each connected node - /// - /// # Arguments - /// * `component_id` - The ID to assign to this component - /// * `node` - The current cube name being processed - /// * `components` - Mutable map of cube -> component_id - /// - /// # Example - /// ```ignore - /// let mut components = HashMap::new(); - /// graph.find_connected_component(1, "users", &mut components); - /// // All cubes reachable from "users" now have component_id = 1 - /// ``` + #[allow(dead_code)] fn find_connected_component( &self, component_id: u32, node: &str, components: &mut HashMap, ) { - // Base case: already visited if components.contains_key(node) { return; } - // Mark this node with component ID components.insert(node.to_string(), component_id); - // Get connected nodes from undirected graph (backward edges: to -> from) if let Some(connected_nodes) = self.undirected_nodes.get(node) { for connected_node in connected_nodes.keys() { self.find_connected_component(component_id, connected_node, components); } } - // Also traverse forward edges (from -> to) if let Some(connected_nodes) = self.nodes.get(node) { for connected_node in connected_nodes.keys() { self.find_connected_component(component_id, connected_node, components); @@ -628,30 +467,8 @@ impl MockJoinGraph { } } - /// Returns connected components of the join graph - /// - /// This method identifies which cubes are connected through join relationships. - /// Cubes in the same component can be joined together. Cubes in different - /// components cannot be joined and would result in a query error. - /// - /// Component IDs start at 1 and increment for each disconnected subgraph. - /// Isolated cubes (with no joins) each get their own unique component ID. - /// - /// # Returns - /// HashMap mapping cube name to component ID (1-based) - /// - /// # Example - /// ```ignore - /// // Graph: users <-> orders, products (isolated) - /// let components = graph.connected_components(); - /// assert_eq!(components.get("users"), components.get("orders")); // Same component - /// assert_ne!(components.get("users"), components.get("products")); // Different - /// ``` - /// - /// # Caching - /// Results are cached and reused on subsequent calls until `compile()` is called. + #[allow(dead_code)] pub fn connected_components(&mut self) -> HashMap { - // Return cached result if available if let Some(cached) = &self.cached_connected_components { return cached.clone(); } @@ -659,18 +476,15 @@ impl MockJoinGraph { let mut component_id: u32 = 1; let mut components: HashMap = HashMap::new(); - // Process all nodes (includes isolated cubes) let node_names: Vec = self.nodes.keys().cloned().collect(); for node in node_names { - // Only process if not already assigned to a component if !components.contains_key(&node) { self.find_connected_component(component_id, &node, &mut components); component_id += 1; } } - // Cache results self.cached_connected_components = Some(components.clone()); components @@ -692,7 +506,6 @@ impl JoinGraph for MockJoinGraph { &self, cubes_to_join: Vec, ) -> Result, CubeError> { - // Call our implementation and cast to trait object let result = self.build_join(cubes_to_join)?; Ok(result as Rc) } 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 c7e3c3ad99907..63975224378ce 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -35,7 +35,6 @@ pub use mock_base_tools::MockBaseTools; pub use mock_case_definition::MockCaseDefinition; pub use mock_case_else_item::MockCaseElseItem; pub use mock_case_item::MockCaseItem; -pub use mock_case_switch_definition::MockCaseSwitchDefinition; pub use mock_case_switch_else_item::MockCaseSwitchElseItem; pub use mock_case_switch_item::MockCaseSwitchItem; pub use mock_cube_definition::MockCubeDefinition; @@ -46,7 +45,7 @@ pub use mock_expression_struct::MockExpressionStruct; pub use mock_geo_item::MockGeoItem; pub use mock_granularity_definition::MockGranularityDefinition; pub use mock_join_definition::MockJoinDefinition; -pub use mock_join_graph::{JoinEdge, MockJoinGraph}; +pub use mock_join_graph::MockJoinGraph; pub use mock_join_item::MockJoinItem; pub use mock_join_item_definition::MockJoinItemDefinition; pub use mock_measure_definition::MockMeasureDefinition; From 7fb8ba75a8e9ee7c1ad2640a91a19f82498e1b4b Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 17 Nov 2025 13:42:48 +0100 Subject: [PATCH 13/13] fix --- .../src/test_fixtures/graph_utils.rs | 73 ------------------- 1 file changed, 73 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs index 96737a67b1c9a..a537a50f53471 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/graph_utils.rs @@ -1,35 +1,3 @@ -//! Graph utilities for MockJoinGraph testing -//! -//! This module provides graph algorithms for testing join path finding in MockJoinGraph. -//! It uses the petgraph library to implement shortest path algorithms (Dijkstra's algorithm -//! via A* with zero heuristic). -//! -//! # Why dev-only -//! -//! This is test infrastructure code that: -//! - Uses petgraph as a dev-dependency (not needed in production) -//! - Converts between our HashMap format and petgraph's graph representation -//! - Provides utilities specifically for testing MockJoinGraph -//! - Is not included in the production binary -//! -//! # Usage -//! -//! ```rust -//! use std::collections::HashMap; -//! use cubesqlplanner::test_fixtures::graph_utils::find_shortest_path; -//! -//! let mut nodes = HashMap::new(); -//! nodes.insert("orders".to_string(), { -//! let mut edges = HashMap::new(); -//! edges.insert("users".to_string(), 1); -//! edges -//! }); -//! nodes.insert("users".to_string(), HashMap::new()); -//! -//! let path = find_shortest_path(&nodes, "orders", "users"); -//! assert_eq!(path, Some(vec!["orders".to_string(), "users".to_string()])); -//! ``` - use petgraph::graph::NodeIndex; use petgraph::Graph; use std::collections::HashMap; @@ -42,20 +10,6 @@ use std::collections::HashMap; /// - Outer map keys are cube names (all nodes in the graph) /// - Inner map represents edges: destination cube name -> edge weight /// -/// Example: -/// ```rust -/// use std::collections::HashMap; -/// -/// let mut nodes = HashMap::new(); -/// nodes.insert("orders".to_string(), { -/// let mut edges = HashMap::new(); -/// edges.insert("users".to_string(), 1); -/// edges.insert("products".to_string(), 1); -/// edges -/// }); -/// nodes.insert("users".to_string(), HashMap::new()); -/// nodes.insert("products".to_string(), HashMap::new()); -/// ``` /// /// # Returns /// @@ -113,51 +67,25 @@ pub fn build_petgraph_from_hashmap( /// - If `start` or `end` don't exist in the graph, returns `None` /// - If nodes are disconnected, returns `None` /// -/// # Example -/// -/// ```rust -/// use std::collections::HashMap; -/// use cubesqlplanner::test_fixtures::graph_utils::find_shortest_path; -/// -/// let mut nodes = HashMap::new(); -/// nodes.insert("A".to_string(), { -/// let mut edges = HashMap::new(); -/// edges.insert("B".to_string(), 1); -/// edges -/// }); -/// nodes.insert("B".to_string(), { -/// let mut edges = HashMap::new(); -/// edges.insert("C".to_string(), 1); -/// edges -/// }); -/// nodes.insert("C".to_string(), HashMap::new()); -/// -/// let path = find_shortest_path(&nodes, "A", "C"); -/// assert_eq!(path, Some(vec!["A".to_string(), "B".to_string(), "C".to_string()])); /// ``` pub fn find_shortest_path( nodes: &HashMap>, start: &str, end: &str, ) -> Option> { - // Edge case: start == end if start == end { return Some(vec![start.to_string()]); } - // Edge case: start or end not in graph if !nodes.contains_key(start) || !nodes.contains_key(end) { return None; } - // Build petgraph from HashMap let (graph, node_indices) = build_petgraph_from_hashmap(nodes); - // Get NodeIndex for start and end let start_index = node_indices[start]; let end_index = node_indices[end]; - // Use A* with zero heuristic (equivalent to Dijkstra) let result = petgraph::algo::astar( &graph, start_index, @@ -166,7 +94,6 @@ pub fn find_shortest_path( |_| 0, // Zero heuristic makes this equivalent to Dijkstra ); - // Convert result to path of cube names match result { Some((_cost, path)) => { let cube_names: Vec = path