diff --git a/packages/cubejs-backend-native/src/bridge_test_exports.rs b/packages/cubejs-backend-native/src/bridge_test_exports.rs index 44fe1ab5a6eef..dd4501b264c5d 100644 --- a/packages/cubejs-backend-native/src/bridge_test_exports.rs +++ b/packages/cubejs-backend-native/src/bridge_test_exports.rs @@ -626,6 +626,7 @@ fn invoke_base_query_options(b: &NativeBaseQueryOptions) -> r.record("join_graph", b.join_graph()); r.record("security_context", b.security_context()); r.record("join_hints", b.join_hints()); + r.record("subquery_joins", b.subquery_joins()); r } diff --git a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts index ea902c5658809..c7ac0e632d105 100644 --- a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts +++ b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts @@ -57,6 +57,7 @@ const BRIDGES: BridgeSpec[] = [ 'row_limit', 'security_context', 'segments', + 'subquery_joins', 'time_dimensions', 'timezone', 'total_query', diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index b328685321a60..552916937d301 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -962,6 +962,7 @@ export class BaseQuery { convertTzForRawTimeDimension: !!this.options.convertTzForRawTimeDimension, maskedMembers: this.options.maskedMembers, memberToAlias: this.options.memberToAlias, + subqueryJoins: this.options.subqueryJoins, }; try { @@ -1011,6 +1012,7 @@ export class BaseQuery { securityContext: this.contextSymbols.securityContext, cubestoreSupportMultistage: this.options.cubestoreSupportMultistage ?? getEnv('cubeStoreRollingWindowJoin'), disableExternalPreAggregations: !!this.options.disableExternalPreAggregations, + subqueryJoins: this.options.subqueryJoins, }; const buildResult = nativeBuildSqlAndParams(queryParams); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs index 593b9311de6eb..f8deae6722bc8 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs @@ -2,6 +2,7 @@ use super::join_graph::{JoinGraph, NativeJoinGraph}; use super::join_hints::JoinHintItem; use super::options_member::OptionsMember; use super::security_context::{NativeSecurityContext, SecurityContext}; +use super::subquery_join::{NativeSubqueryJoin, SubqueryJoin}; use crate::cube_bridge::base_tools::{BaseTools, NativeBaseTools}; use crate::cube_bridge::evaluator::{CubeEvaluator, NativeCubeEvaluator}; use cubenativeutils::wrappers::serializer::{ @@ -239,6 +240,8 @@ pub trait BaseQueryOptions { fn security_context(&self) -> Result, CubeError>; #[nbridge(field, optional, vec)] fn join_hints(&self) -> Result>, CubeError>; + #[nbridge(field, optional, vec)] + fn subquery_joins(&self) -> Result>>, CubeError>; } #[cfg(test)] diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs index 19759f1796dcd..04d1875af1116 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs @@ -45,5 +45,6 @@ pub mod sql_templates_render; pub mod sql_utils; pub mod string_or_sql; pub mod struct_with_sql_member; +pub mod subquery_join; pub mod timeshift_definition; pub mod view_filter_definition; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/subquery_join.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/subquery_join.rs new file mode 100644 index 0000000000000..4d8c50e8da772 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/subquery_join.rs @@ -0,0 +1,27 @@ +use super::member_expression::{MemberExpressionDefinition, NativeMemberExpressionDefinition}; +use cubenativeutils::wrappers::serializer::{ + NativeDeserialize, NativeDeserializer, NativeSerialize, +}; +use cubenativeutils::wrappers::{NativeContextHolder, NativeObjectHandle}; +use cubenativeutils::CubeError; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::rc::Rc; + +/// A query-level join against an opaque sub-query, originating from the +/// SQL API (cubesql) `subqueryJoins`. `sql` is a complete, pre-rendered +/// SELECT; `on` is the join condition expressed as a member expression +/// (same shape as a parsed member expression: `cubeName` + `expression`). +#[derive(Serialize, Deserialize, Debug, Clone, nativebridge::NativeBridgeStatic)] +pub struct SubqueryJoinStatic { + pub sql: String, + #[serde(rename = "joinType")] + pub join_type: Option, + pub alias: String, +} + +#[nativebridge::native_bridge(SubqueryJoinStatic, with_static_meta)] +pub trait SubqueryJoin { + #[nbridge(field)] + fn on(&self) -> Result, CubeError>; +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/join.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/join.rs index 6910aa959bd29..884eac91c6507 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/join.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/join.rs @@ -31,6 +31,29 @@ impl PrettyPrint for LogicalJoinItem { } } +/// A query-level join against an opaque pre-rendered sub-query, sourced +/// from the SQL API `subqueryJoins`. `sql` is a complete SELECT emitted +/// verbatim; `on_sql` is the compiled join condition (it references the +/// owning cube and the sub-query `alias` as a literal). Unlike +/// `LogicalJoinItem` there is no joined `Cube` node, so these are carried +/// as opaque data and never participate in input packing. +#[derive(Clone)] +pub struct LogicalSubqueryJoinItem { + pub sql: String, + pub alias: String, + pub join_type: String, + pub on_sql: Rc, +} + +impl PrettyPrint for LogicalSubqueryJoinItem { + fn pretty_print(&self, result: &mut PrettyPrintResult, state: &PrettyPrintState) { + result.println( + &format!("SubqueryJoinItem: {} AS {}", self.join_type, self.alias), + state, + ); + } +} + /// Join of cubes that backs a query source: a `root` cube plus /// non-root cubes (`joins`), optionally extended by sub-query /// dimensions that contribute their own joined-in CTEs. @@ -42,6 +65,8 @@ pub struct LogicalJoin { joins: Vec, #[builder(default)] dimension_subqueries: Vec>, + #[builder(default)] + subquery_joins: Vec, } impl LogicalJoin { @@ -56,6 +81,22 @@ impl LogicalJoin { pub fn dimension_subqueries(&self) -> &Vec> { &self.dimension_subqueries } + + pub fn subquery_joins(&self) -> &Vec { + &self.subquery_joins + } + + /// Returns a copy of this join with the given query-level sub-query + /// joins attached. Used to fold in SQL-API `subqueryJoins` after the + /// cube join tree has been built. + pub fn with_subquery_joins(&self, subquery_joins: Vec) -> Rc { + Rc::new(Self { + root: self.root.clone(), + joins: self.joins.clone(), + dimension_subqueries: self.dimension_subqueries.clone(), + subquery_joins, + }) + } } impl LogicalNode for LogicalJoin { @@ -101,6 +142,9 @@ impl LogicalNode for LogicalJoin { .map(|itm| itm.clone().into_logical_node()) .collect::, _>>()?, ) + // Sub-query joins are opaque data (no child plan nodes), so they are + // not packed/unpacked as inputs; clone them through unchanged. + .subquery_joins(self.subquery_joins.clone()) .build(); Ok(Rc::new(result)) @@ -191,6 +235,13 @@ impl PrettyPrint for LogicalJoin { subquery.pretty_print(result, &details_state); } } + if !self.subquery_joins().is_empty() { + result.println("subquery_joins:", &state); + let details_state = state.new_level(); + for subquery_join in self.subquery_joins().iter() { + subquery_join.pretty_print(result, &details_state); + } + } } else { result.println(&format!("Empty source"), state); } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/from.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/from.rs index c6c627d690b70..2fb4f82ab7b7e 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/from.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/from.rs @@ -10,6 +10,10 @@ pub enum SingleSource { Subquery(Rc), Cube(Rc), TableReference(String, Rc), + /// An opaque, pre-rendered SQL sub-query (a complete SELECT) emitted + /// verbatim wrapped in parentheses. Used for SQL-API `subqueryJoins`, + /// whose body is built outside the planner. + RawSubquerySql(String), } impl SingleSource { @@ -25,6 +29,7 @@ impl SingleSource { } SingleSource::Subquery(s) => format!("({})", s.to_sql(templates)?), SingleSource::TableReference(r, _) => format!(" {} ", r), + SingleSource::RawSubquerySql(sql) => format!("({})", sql), }; Ok(sql) } @@ -34,6 +39,7 @@ impl SingleSource { SingleSource::Subquery(subquery) => subquery.schema(), SingleSource::Cube(_) => Rc::new(Schema::empty()), SingleSource::TableReference(_, schema) => schema.clone(), + SingleSource::RawSubquerySql(_) => Rc::new(Schema::empty()), } } } @@ -91,7 +97,13 @@ impl SingleAliasedSource { } } */ - templates.query_aliased(&sql, &self.alias) + match &self.source { + // The alias of a SQL-API sub-query join is already a final, quoted + // identifier and is referenced verbatim in the join ON condition. + // Re-quoting it would double the quotes and break the reference. + SingleSource::RawSubquerySql(_) => templates.query_aliased_prequoted(&sql, &self.alias), + _ => templates.query_aliased(&sql, &self.alias), + } } } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/references_builder.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/references_builder.rs index 73b18bf2d13a4..a57901823edd7 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/references_builder.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/references_builder.rs @@ -272,6 +272,9 @@ impl ReferencesBuilder { } SingleSource::Cube(_) => None, SingleSource::TableReference(_, schema) => schema.resolve_member_reference(member), + // Opaque pre-rendered SQL: members are referenced via the alias as + // literals in the ON/projection SQL, not resolved against a schema. + SingleSource::RawSubquerySql(_) => None, }; column_name.map(|col| QualifiedColumnName::new(Some(source.alias.clone()), col)) } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/logical_join.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/logical_join.rs index 9a04cb0834ce4..63e7ce06f72ba 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/logical_join.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/logical_join.rs @@ -1,6 +1,6 @@ use super::super::{LogicalNodeProcessor, ProcessableNode, PushDownBuilderContext}; use crate::logical_plan::LogicalJoin; -use crate::physical_plan::{From, JoinBuilder, JoinCondition}; +use crate::physical_plan::{From, JoinBuilder, JoinCondition, SingleSource}; use crate::physical_plan_builder::PhysicalPlanBuilder; use crate::planner::SqlJoinCondition; use cubenativeutils::CubeError; @@ -38,6 +38,7 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> { let root = logical_join.root().clone().unwrap().cube().clone(); if logical_join.joins().is_empty() && logical_join.dimension_subqueries().is_empty() + && logical_join.subquery_joins().is_empty() && multi_stage_dimension.is_none() { Ok(From::new_from_cube( @@ -61,6 +62,7 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> { context, )?; } + for join in logical_join.joins().iter() { join_builder.left_join_cube( join.cube().cube().clone(), @@ -83,6 +85,7 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> { )?; } } + if let Some(multi_stage_dimension) = &multi_stage_dimension { self.builder.add_multistage_dimension_join( multi_stage_dimension, @@ -90,6 +93,25 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> { &context, )?; } + + for subquery_join in logical_join.subquery_joins().iter() { + let source = SingleSource::RawSubquerySql(subquery_join.sql.clone()); + let on = JoinCondition::new_base_join(SqlJoinCondition::try_new( + subquery_join.on_sql.clone(), + )?); + + if subquery_join.join_type.eq_ignore_ascii_case("INNER") { + join_builder.inner_join_source(source, subquery_join.alias.clone(), on); + } else if subquery_join.join_type.eq_ignore_ascii_case("LEFT") { + join_builder.left_join_source(source, subquery_join.alias.clone(), on); + } else { + return Err(CubeError::user(format!( + "Unsupported join type '{}' for sub-query join, expected INNER or LEFT", + subquery_join.join_type + ))); + } + } + Ok(From::new_from_join(join_builder.build())) } } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/simple_query_planer.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/simple_query_planer.rs index 802fe5c427266..0b0bf6a91ef63 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/simple_query_planer.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/simple_query_planer.rs @@ -1,6 +1,7 @@ use super::{DimensionSubqueryPlanner, JoinPlanner}; use crate::logical_plan::*; -use crate::planner::collectors::collect_sub_query_dimensions_from_symbols; +use crate::planner::collectors::{collect_join_hints, collect_sub_query_dimensions_from_symbols}; +use crate::planner::join_hints::JoinHints; use crate::planner::planners::multi_stage::PlanningScope; use crate::planner::state::State; use crate::planner::QueryProperties; @@ -77,12 +78,33 @@ impl SimpleQueryPlanner { )?; let subquery_dimension_queries = dimension_subquery_planner.plan_queries(&subquery_dimensions, scope)?; + // Query-level SQL-API sub-query joins (from `subqueryJoins`) extend the + // FROM clause with opaque joined sub-queries. + let subquery_joins = self.query_properties.subquery_joins(); let source = if let Some(join) = &join { self.join_planner .make_join_logical_plan(join, subquery_dimension_queries) + } else if !subquery_joins.is_empty() { + // No selected member resolves the base cube, but the sub-query joins' + // ON conditions reference it (e.g. `SELECT t.col FROM Cube JOIN (...) t + // ON Cube.x = t.x`). Derive the join root from those ON references so + // the base cube — and the sub-query joins below — are emitted. + let mut hints = JoinHints::new(); + for subquery_join in subquery_joins { + for dep in subquery_join.on_sql.get_dependencies() { + hints.extend(&collect_join_hints(&dep)?); + } + } + self.join_planner + .make_join_logical_plan_with_join_hints(hints, subquery_dimension_queries)? } else { self.join_planner.make_empty_join_logical_plan() }; + let source = if subquery_joins.is_empty() { + source + } else { + source.with_subquery_joins(subquery_joins.clone()) + }; Ok(source) } } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs index 9befe2f6b0dcd..77219e0434449 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs @@ -9,6 +9,7 @@ use super::state::State; use super::MemberSymbol; use crate::cube_bridge::base_query_options::FilterValue; +use crate::logical_plan::LogicalSubqueryJoinItem; use crate::planner::collectors::{collect_multiplied_measures, has_multi_stage_members}; use crate::planner::filter::tree_ops; use crate::planner::filter::{Filter, FilterGroup, FilterItem, FilterOperator}; @@ -176,6 +177,10 @@ pub struct QueryProperties { disable_external_pre_aggregations: bool, #[builder(default)] pre_aggregation_id: Option, + /// Query-level joins against opaque sub-queries, from the SQL API + /// `subqueryJoins`. Folded into the query's `LogicalJoin` source. + #[builder(default)] + subquery_joins: Vec, #[builder(setter(skip), default)] multi_fact_join_groups: OnceCell, } @@ -207,6 +212,10 @@ impl QueryProperties { self.allow_multi_stage } + pub fn subquery_joins(&self) -> &Vec { + &self.subquery_joins + } + // Push every entry of `dimensions_filters` into matching `case` // expressions on each member, filter and order item. Run once at // construction; mutators do not re-apply it. @@ -1140,6 +1149,7 @@ impl PartialEq for QueryProperties { disable_external_pre_aggregations, pre_aggregation_id, query_join_hints, + subquery_joins, // Not part of semantic equality: query_tools: _, multi_fact_join_groups: _, @@ -1164,5 +1174,14 @@ impl PartialEq for QueryProperties { && *disable_external_pre_aggregations == other.disable_external_pre_aggregations && *pre_aggregation_id == other.pre_aggregation_id && *query_join_hints == other.query_join_hints + // Compare sub-query joins by their request-derived identity (the + // compiled `on_sql` is derived deterministically from these). + && subquery_joins.len() == other.subquery_joins.len() + && subquery_joins + .iter() + .zip(other.subquery_joins.iter()) + .all(|(a, b)| { + a.sql == b.sql && a.alias == b.alias && a.join_type == b.join_type + }) } } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs index 7db89bc58df7b..04d79e9fea297 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs @@ -16,6 +16,7 @@ use crate::cube_bridge::member_expression::{ }; use crate::cube_bridge::options_member::OptionsMember; use crate::cube_bridge::view_filter_definition::ViewFilterDefinition; +use crate::logical_plan::LogicalSubqueryJoinItem; use super::filter::compiler::FilterCompiler; use super::filter::{BaseSegment, FilterItem}; @@ -92,6 +93,8 @@ impl QueryPropertiesCompiler { options.join_hints()?.unwrap_or_default(), )); + let subquery_joins = self.compile_subquery_joins(&mut evaluator_compiler, options)?; + QueryProperties::builder() .query_tools(self.query_tools) .measures(measures) @@ -110,9 +113,53 @@ impl QueryPropertiesCompiler { .query_join_hints(query_join_hints) .disable_external_pre_aggregations(disable_external_pre_aggregations) .pre_aggregation_id(pre_aggregation_id) + .subquery_joins(subquery_joins) .build() } + /// Compiles SQL-API `subqueryJoins` into [`LogicalSubqueryJoinItem`]s: + /// keeps the opaque sub-query `sql`/`alias`/`joinType` and compiles the + /// `on` condition (a member expression) into a `SqlCall` bound to its + /// declared cube. + fn compile_subquery_joins( + &self, + evaluator_compiler: &mut Compiler, + options: &dyn BaseQueryOptions, + ) -> Result, CubeError> { + let Some(subquery_joins) = options.subquery_joins()? else { + return Ok(Vec::new()); + }; + subquery_joins + .iter() + .map(|join| -> Result { + let static_data = join.static_data(); + let on = join.on()?; + let on_cube_name = on.static_data().cube_name.clone().unwrap_or_default(); + let on_sql = match on.expression()? { + MemberExpressionExpressionDef::Sql(sql) => { + evaluator_compiler.compile_sql_call(&on_cube_name, sql)? + } + MemberExpressionExpressionDef::Struct(_) => { + return Err(CubeError::user( + "Struct expression is not supported for subquery join condition" + .to_string(), + )); + } + }; + let join_type = static_data + .join_type + .clone() + .unwrap_or_else(|| "LEFT".to_string()); + Ok(LogicalSubqueryJoinItem { + sql: static_data.sql.clone(), + alias: static_data.alias.clone(), + join_type, + on_sql, + }) + }) + .collect() + } + fn compile_dimensions( &self, evaluator_compiler: &mut Compiler, diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/sql_templates/plan.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/sql_templates/plan.rs index c067a47837920..318c357f9d324 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/sql_templates/plan.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/sql_templates/plan.rs @@ -247,6 +247,21 @@ impl PlanSqlTemplates { ) } + /// Like [`Self::query_aliased`] but takes an alias that is already a final, + /// quote-ready identifier and must not be re-quoted. Used for SQL-API + /// sub-query joins, whose alias the SQL API emits pre-quoted and references + /// verbatim in the ON condition. + pub fn query_aliased_prequoted( + &self, + query: &str, + quoted_alias: &str, + ) -> Result { + self.render.render_template( + "expressions/query_aliased", + context! { query => query, quoted_alias => quoted_alias }, + ) + } + pub fn order_by( &self, expr: &str, diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs index 6a4940fe26ae9..bca4b3d7d78ca 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs @@ -17,6 +17,7 @@ use crate::{ join_hints::JoinHintItem, options_member::OptionsMember, security_context::SecurityContext, + subquery_join::SubqueryJoin, }, impl_static_data, }; @@ -39,6 +40,8 @@ pub struct MockBaseQueryOptions { segments: Option>, #[builder(default)] join_hints: Option>, + #[builder(default)] + subquery_joins: Option>>, // Fields from BaseQueryOptionsStatic #[builder(default)] @@ -202,6 +205,14 @@ impl BaseQueryOptions for MockBaseQueryOptions { Ok(self.join_hints.clone()) } + fn has_subquery_joins(&self) -> Result { + Ok(self.subquery_joins.is_some()) + } + + fn subquery_joins(&self) -> Result>>, CubeError> { + Ok(self.subquery_joins.clone()) + } + fn as_any(self: Rc) -> Rc { self } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_subquery_join.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_subquery_join.rs new file mode 100644 index 0000000000000..90974e9165bb2 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_subquery_join.rs @@ -0,0 +1,33 @@ +use crate::cube_bridge::member_expression::MemberExpressionDefinition; +use crate::cube_bridge::subquery_join::{SubqueryJoin, SubqueryJoinStatic}; +use crate::impl_static_data; +use cubenativeutils::CubeError; +use std::any::Any; +use std::rc::Rc; +use typed_builder::TypedBuilder; + +/// Mock implementation of `SubqueryJoin` for testing SQL-API grouped +/// sub-query joins (`subqueryJoins`). +#[derive(TypedBuilder)] +pub struct MockSubqueryJoin { + sql: String, + #[builder(default)] + join_type: Option, + alias: String, + // Trait field: the join ON condition, expressed as a member expression. + on: Rc, +} + +impl_static_data!(MockSubqueryJoin, SubqueryJoinStatic, sql, join_type, alias); + +impl SubqueryJoin for MockSubqueryJoin { + crate::impl_static_data_method!(SubqueryJoinStatic); + + fn on(&self) -> Result, CubeError> { + Ok(self.on.clone()) + } + + fn as_any(self: Rc) -> Rc { + self + } +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs index d201c77b6395f..2e7b67ec55b39 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -37,6 +37,7 @@ mod mock_segment_definition; mod mock_sql_templates_render; mod mock_sql_utils; mod mock_struct_with_sql_member; +mod mock_subquery_join; mod mock_timeshift_definition; mod mock_view_filter_definition; @@ -73,5 +74,6 @@ pub use mock_segment_definition::MockSegmentDefinition; pub use mock_sql_templates_render::MockSqlTemplatesRender; pub use mock_sql_utils::MockSqlUtils; pub use mock_struct_with_sql_member::MockStructWithSqlMember; +pub use mock_subquery_join::MockSubqueryJoin; pub use mock_timeshift_definition::MockTimeShiftDefinition; pub use mock_view_filter_definition::MockViewFilterDefinition; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs index 18d5735bdfb12..ec768bbfac1e1 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs @@ -1,11 +1,13 @@ use crate::cube_bridge::member_expression::MemberExpressionExpressionDef; use crate::cube_bridge::member_sql::MemberSql; use crate::cube_bridge::options_member::OptionsMember; +use crate::cube_bridge::subquery_join::SubqueryJoin; use crate::test_fixtures::cube_bridge::{ members_from_strings, MockBaseQueryOptions, MockMemberExpressionDefinition, MockMemberSql, - MockSchema, + MockSchema, MockSubqueryJoin, }; use crate::test_fixtures::test_utils::TestContext; +use cubenativeutils::CubeError; use std::rc::Rc; const SEED: &str = "integration_basic_tables.sql"; @@ -37,6 +39,33 @@ fn make_measure_expression(name: &str, cube: &str, sql: &str) -> OptionsMember { OptionsMember::MemberExpression(Rc::new(expr)) } +// Mirrors a SQL-API `subqueryJoins` entry: opaque sub-query `sql`, a join type +// and alias, and an `on` condition expressed as a member expression (the alias +// arrives pre-quoted and is referenced verbatim inside `on`). +fn make_subquery_join( + sql: &str, + alias: &str, + join_type: &str, + on_cube: &str, + on_sql: &str, +) -> Result, CubeError> { + let on_member_sql: Rc = Rc::new(MockMemberSql::new(on_sql)?); + let on: Rc = Rc::new( + MockMemberExpressionDefinition::builder() + .cube_name(Some(on_cube.to_string())) + .expression(MemberExpressionExpressionDef::Sql(on_member_sql)) + .build(), + ); + Ok(Rc::new( + MockSubqueryJoin::builder() + .sql(sql.to_string()) + .join_type(Some(join_type.to_string())) + .alias(alias.to_string()) + .on(on) + .build(), + )) +} + // LOWER(status) as dimension expression + count // completed:5, pending:3, cancelled:1 #[tokio::test(flavor = "multi_thread")] @@ -205,3 +234,186 @@ async fn test_multiplied_dim_only_me_measure() { insta::assert_snapshot!(result); } } + +const TOP_ORDERS_SUBQUERY: &str = + "SELECT status, SUM(amount) FROM orders GROUP BY 1 ORDER BY 2 DESC LIMIT 2"; + +// A SQL-API grouped sub-query join: the opaque sub-query (with its inner +// ORDER BY/LIMIT) must be emitted verbatim, INNER-joined under the +// pre-quoted alias used verbatim in the ON condition. +#[tokio::test(flavor = "multi_thread")] +async fn test_subquery_join_grouped() -> Result<(), CubeError> { + let ctx = create_context(); + let subquery_join = make_subquery_join( + TOP_ORDERS_SUBQUERY, + "\"top_orders\"", + "INNER", + "orders", + "{orders.status} = \"top_orders\".status", + )?; + + let options = Rc::new( + MockBaseQueryOptions::builder() + .cube_evaluator(ctx.query_tools().cube_evaluator().clone()) + .base_tools(ctx.query_tools().base_tools().clone()) + .join_graph(ctx.query_tools().join_graph().clone()) + .security_context(ctx.security_context().clone()) + .measures(Some(members_from_strings(vec!["orders.count"]))) + .dimensions(Some(members_from_strings(vec!["orders.status"]))) + .subquery_joins(Some(vec![subquery_join])) + .build(), + ); + + let sql = ctx.build_sql_from_options(options.clone())?; + + // The opaque sub-query is emitted verbatim (inner ORDER BY/LIMIT preserved). + assert!( + sql.contains(TOP_ORDERS_SUBQUERY), + "sub-query SQL should be emitted verbatim, got: {sql}" + ); + assert!( + sql.contains("INNER JOIN"), + "expected INNER JOIN, got: {sql}" + ); + // The pre-quoted alias is emitted as-is, not re-quoted. + assert!( + sql.contains("\"top_orders\"") && !sql.contains("\"\"\"top_orders\"\"\""), + "alias should be emitted verbatim (no re-quoting), got: {sql}" + ); + assert!( + sql.contains("\"top_orders\".status"), + "expected ON condition referencing the sub-query alias, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg_from_options(options, SEED).await { + insta::assert_snapshot!(result); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subquery_join_grouped_left() -> Result<(), CubeError> { + let ctx = create_context(); + let subquery_join = make_subquery_join( + TOP_ORDERS_SUBQUERY, + "\"top_orders\"", + "LEFT", + "orders", + "{orders.status} = \"top_orders\".status", + )?; + + let options = Rc::new( + MockBaseQueryOptions::builder() + .cube_evaluator(ctx.query_tools().cube_evaluator().clone()) + .base_tools(ctx.query_tools().base_tools().clone()) + .join_graph(ctx.query_tools().join_graph().clone()) + .security_context(ctx.security_context().clone()) + .measures(Some(members_from_strings(vec!["orders.count"]))) + .dimensions(Some(members_from_strings(vec!["orders.status"]))) + .subquery_joins(Some(vec![subquery_join])) + .build(), + ); + + let sql = ctx.build_sql_from_options(options.clone())?; + + assert!( + sql.contains(TOP_ORDERS_SUBQUERY), + "sub-query SQL should be emitted verbatim, got: {sql}" + ); + assert!(sql.contains("LEFT JOIN"), "expected LEFT JOIN, got: {sql}"); + // The pre-quoted alias is emitted as-is, not re-quoted. + assert!( + sql.contains("\"top_orders\"") && !sql.contains("\"\"\"top_orders\"\"\""), + "alias should be emitted verbatim (no re-quoting), got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg_from_options(options, SEED).await { + insta::assert_snapshot!(result); + } + + Ok(()) +} + +#[test] +fn test_subquery_join_unknown_join_type() -> Result<(), CubeError> { + let ctx = create_context(); + let subquery_join = make_subquery_join( + TOP_ORDERS_SUBQUERY, + "\"top_orders\"", + "RIGHT", + "orders", + "{orders.status} = \"top_orders\".status", + )?; + + let options = Rc::new( + MockBaseQueryOptions::builder() + .cube_evaluator(ctx.query_tools().cube_evaluator().clone()) + .base_tools(ctx.query_tools().base_tools().clone()) + .join_graph(ctx.query_tools().join_graph().clone()) + .security_context(ctx.security_context().clone()) + .measures(Some(members_from_strings(vec!["orders.count"]))) + .dimensions(Some(members_from_strings(vec!["orders.status"]))) + .subquery_joins(Some(vec![subquery_join])) + .build(), + ); + + let err = ctx + .build_sql_from_options(options) + .expect_err("unsupported join type should be rejected"); + assert!( + err.message.contains("Unsupported join type") && err.message.contains("RIGHT"), + "expected a clear unsupported-join-type error, got: {}", + err.message + ); + + Ok(()) +} + +// Empty-members case: only the sub-query column is projected, so no `orders` +// member selects the base cube. The join root is derived from the ON +// dependencies so the base cube and the sub-query join are still emitted. +#[tokio::test(flavor = "multi_thread")] +async fn test_subquery_join_empty_members() -> Result<(), CubeError> { + let ctx = create_context(); + let subquery_join = make_subquery_join( + TOP_ORDERS_SUBQUERY, + "\"top_orders\"", + "INNER", + "orders", + "{orders.status} = \"top_orders\".status", + )?; + let top_status = make_dim_expression("top_status", "orders", "\"top_orders\".status"); + + let options = Rc::new( + MockBaseQueryOptions::builder() + .cube_evaluator(ctx.query_tools().cube_evaluator().clone()) + .base_tools(ctx.query_tools().base_tools().clone()) + .join_graph(ctx.query_tools().join_graph().clone()) + .security_context(ctx.security_context().clone()) + .dimensions(Some(vec![top_status])) + .subquery_joins(Some(vec![subquery_join])) + .build(), + ); + + let sql = ctx.build_sql_from_options(options.clone())?; + + assert!( + sql.contains("INNER JOIN"), + "expected INNER JOIN, got: {sql}" + ); + assert!( + sql.contains(TOP_ORDERS_SUBQUERY), + "sub-query SQL should be emitted verbatim, got: {sql}" + ); + assert!( + sql.contains("\"top_orders\".status"), + "expected reference to the sub-query alias, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg_from_options(options, SEED).await { + insta::assert_snapshot!(result); + } + + Ok(()) +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_empty_members.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_empty_members.snap new file mode 100644 index 0000000000000..97051b2a9b09f --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_empty_members.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs +expression: result +--- +top_status +---------- +completed +pending diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped.snap new file mode 100644 index 0000000000000..97c1375a09125 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs +expression: result +--- +orders__status | orders__count +---------------+-------------- +completed | 5 +pending | 3 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped_left.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped_left.snap new file mode 100644 index 0000000000000..be40cc1a31b0d --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__member_expressions__subquery_join_grouped_left.snap @@ -0,0 +1,9 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/member_expressions.rs +expression: result +--- +orders__status | orders__count +---------------+-------------- +completed | 5 +pending | 3 +cancelled | 1