Skip to content

Commit 4d4b8cd

Browse files
authored
feat(tesseract): Support SQL API grouped sub-query joins (cube-js#11138)
1 parent a4a3e79 commit 4d4b8cd

22 files changed

Lines changed: 628 additions & 4 deletions

packages/cubejs-backend-native/src/bridge_test_exports.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ fn invoke_base_query_options<IT: InnerTypes>(b: &NativeBaseQueryOptions<IT>) ->
626626
r.record("join_graph", b.join_graph());
627627
r.record("security_context", b.security_context());
628628
r.record("join_hints", b.join_hints());
629+
r.record("subquery_joins", b.subquery_joins());
629630
r
630631
}
631632

packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const BRIDGES: BridgeSpec[] = [
5757
'row_limit',
5858
'security_context',
5959
'segments',
60+
'subquery_joins',
6061
'time_dimensions',
6162
'timezone',
6263
'total_query',

packages/cubejs-schema-compiler/src/adapter/BaseQuery.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ export class BaseQuery {
962962
convertTzForRawTimeDimension: !!this.options.convertTzForRawTimeDimension,
963963
maskedMembers: this.options.maskedMembers,
964964
memberToAlias: this.options.memberToAlias,
965+
subqueryJoins: this.options.subqueryJoins,
965966
};
966967

967968
try {
@@ -1011,6 +1012,7 @@ export class BaseQuery {
10111012
securityContext: this.contextSymbols.securityContext,
10121013
cubestoreSupportMultistage: this.options.cubestoreSupportMultistage ?? getEnv('cubeStoreRollingWindowJoin'),
10131014
disableExternalPreAggregations: !!this.options.disableExternalPreAggregations,
1015+
subqueryJoins: this.options.subqueryJoins,
10141016
};
10151017

10161018
const buildResult = nativeBuildSqlAndParams(queryParams);

rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::join_graph::{JoinGraph, NativeJoinGraph};
22
use super::join_hints::JoinHintItem;
33
use super::options_member::OptionsMember;
44
use super::security_context::{NativeSecurityContext, SecurityContext};
5+
use super::subquery_join::{NativeSubqueryJoin, SubqueryJoin};
56
use crate::cube_bridge::base_tools::{BaseTools, NativeBaseTools};
67
use crate::cube_bridge::evaluator::{CubeEvaluator, NativeCubeEvaluator};
78
use cubenativeutils::wrappers::serializer::{
@@ -239,6 +240,8 @@ pub trait BaseQueryOptions {
239240
fn security_context(&self) -> Result<Rc<dyn SecurityContext>, CubeError>;
240241
#[nbridge(field, optional, vec)]
241242
fn join_hints(&self) -> Result<Option<Vec<JoinHintItem>>, CubeError>;
243+
#[nbridge(field, optional, vec)]
244+
fn subquery_joins(&self) -> Result<Option<Vec<Rc<dyn SubqueryJoin>>>, CubeError>;
242245
}
243246

244247
#[cfg(test)]

rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ pub mod sql_templates_render;
4545
pub mod sql_utils;
4646
pub mod string_or_sql;
4747
pub mod struct_with_sql_member;
48+
pub mod subquery_join;
4849
pub mod timeshift_definition;
4950
pub mod view_filter_definition;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use super::member_expression::{MemberExpressionDefinition, NativeMemberExpressionDefinition};
2+
use cubenativeutils::wrappers::serializer::{
3+
NativeDeserialize, NativeDeserializer, NativeSerialize,
4+
};
5+
use cubenativeutils::wrappers::{NativeContextHolder, NativeObjectHandle};
6+
use cubenativeutils::CubeError;
7+
use serde::{Deserialize, Serialize};
8+
use std::any::Any;
9+
use std::rc::Rc;
10+
11+
/// A query-level join against an opaque sub-query, originating from the
12+
/// SQL API (cubesql) `subqueryJoins`. `sql` is a complete, pre-rendered
13+
/// SELECT; `on` is the join condition expressed as a member expression
14+
/// (same shape as a parsed member expression: `cubeName` + `expression`).
15+
#[derive(Serialize, Deserialize, Debug, Clone, nativebridge::NativeBridgeStatic)]
16+
pub struct SubqueryJoinStatic {
17+
pub sql: String,
18+
#[serde(rename = "joinType")]
19+
pub join_type: Option<String>,
20+
pub alias: String,
21+
}
22+
23+
#[nativebridge::native_bridge(SubqueryJoinStatic, with_static_meta)]
24+
pub trait SubqueryJoin {
25+
#[nbridge(field)]
26+
fn on(&self) -> Result<Rc<dyn MemberExpressionDefinition>, CubeError>;
27+
}

rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/join.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ impl PrettyPrint for LogicalJoinItem {
3131
}
3232
}
3333

34+
/// A query-level join against an opaque pre-rendered sub-query, sourced
35+
/// from the SQL API `subqueryJoins`. `sql` is a complete SELECT emitted
36+
/// verbatim; `on_sql` is the compiled join condition (it references the
37+
/// owning cube and the sub-query `alias` as a literal). Unlike
38+
/// `LogicalJoinItem` there is no joined `Cube` node, so these are carried
39+
/// as opaque data and never participate in input packing.
40+
#[derive(Clone)]
41+
pub struct LogicalSubqueryJoinItem {
42+
pub sql: String,
43+
pub alias: String,
44+
pub join_type: String,
45+
pub on_sql: Rc<SqlCall>,
46+
}
47+
48+
impl PrettyPrint for LogicalSubqueryJoinItem {
49+
fn pretty_print(&self, result: &mut PrettyPrintResult, state: &PrettyPrintState) {
50+
result.println(
51+
&format!("SubqueryJoinItem: {} AS {}", self.join_type, self.alias),
52+
state,
53+
);
54+
}
55+
}
56+
3457
/// Join of cubes that backs a query source: a `root` cube plus
3558
/// non-root cubes (`joins`), optionally extended by sub-query
3659
/// dimensions that contribute their own joined-in CTEs.
@@ -42,6 +65,8 @@ pub struct LogicalJoin {
4265
joins: Vec<LogicalJoinItem>,
4366
#[builder(default)]
4467
dimension_subqueries: Vec<Rc<DimensionSubQuery>>,
68+
#[builder(default)]
69+
subquery_joins: Vec<LogicalSubqueryJoinItem>,
4570
}
4671

4772
impl LogicalJoin {
@@ -56,6 +81,22 @@ impl LogicalJoin {
5681
pub fn dimension_subqueries(&self) -> &Vec<Rc<DimensionSubQuery>> {
5782
&self.dimension_subqueries
5883
}
84+
85+
pub fn subquery_joins(&self) -> &Vec<LogicalSubqueryJoinItem> {
86+
&self.subquery_joins
87+
}
88+
89+
/// Returns a copy of this join with the given query-level sub-query
90+
/// joins attached. Used to fold in SQL-API `subqueryJoins` after the
91+
/// cube join tree has been built.
92+
pub fn with_subquery_joins(&self, subquery_joins: Vec<LogicalSubqueryJoinItem>) -> Rc<Self> {
93+
Rc::new(Self {
94+
root: self.root.clone(),
95+
joins: self.joins.clone(),
96+
dimension_subqueries: self.dimension_subqueries.clone(),
97+
subquery_joins,
98+
})
99+
}
59100
}
60101

61102
impl LogicalNode for LogicalJoin {
@@ -101,6 +142,9 @@ impl LogicalNode for LogicalJoin {
101142
.map(|itm| itm.clone().into_logical_node())
102143
.collect::<Result<Vec<_>, _>>()?,
103144
)
145+
// Sub-query joins are opaque data (no child plan nodes), so they are
146+
// not packed/unpacked as inputs; clone them through unchanged.
147+
.subquery_joins(self.subquery_joins.clone())
104148
.build();
105149

106150
Ok(Rc::new(result))
@@ -191,6 +235,13 @@ impl PrettyPrint for LogicalJoin {
191235
subquery.pretty_print(result, &details_state);
192236
}
193237
}
238+
if !self.subquery_joins().is_empty() {
239+
result.println("subquery_joins:", &state);
240+
let details_state = state.new_level();
241+
for subquery_join in self.subquery_joins().iter() {
242+
subquery_join.pretty_print(result, &details_state);
243+
}
244+
}
194245
} else {
195246
result.println(&format!("Empty source"), state);
196247
}

rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/from.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ pub enum SingleSource {
1010
Subquery(Rc<QueryPlan>),
1111
Cube(Rc<BaseCube>),
1212
TableReference(String, Rc<Schema>),
13+
/// An opaque, pre-rendered SQL sub-query (a complete SELECT) emitted
14+
/// verbatim wrapped in parentheses. Used for SQL-API `subqueryJoins`,
15+
/// whose body is built outside the planner.
16+
RawSubquerySql(String),
1317
}
1418

1519
impl SingleSource {
@@ -25,6 +29,7 @@ impl SingleSource {
2529
}
2630
SingleSource::Subquery(s) => format!("({})", s.to_sql(templates)?),
2731
SingleSource::TableReference(r, _) => format!(" {} ", r),
32+
SingleSource::RawSubquerySql(sql) => format!("({})", sql),
2833
};
2934
Ok(sql)
3035
}
@@ -34,6 +39,7 @@ impl SingleSource {
3439
SingleSource::Subquery(subquery) => subquery.schema(),
3540
SingleSource::Cube(_) => Rc::new(Schema::empty()),
3641
SingleSource::TableReference(_, schema) => schema.clone(),
42+
SingleSource::RawSubquerySql(_) => Rc::new(Schema::empty()),
3743
}
3844
}
3945
}
@@ -91,7 +97,13 @@ impl SingleAliasedSource {
9197
}
9298
} */
9399

94-
templates.query_aliased(&sql, &self.alias)
100+
match &self.source {
101+
// The alias of a SQL-API sub-query join is already a final, quoted
102+
// identifier and is referenced verbatim in the join ON condition.
103+
// Re-quoting it would double the quotes and break the reference.
104+
SingleSource::RawSubquerySql(_) => templates.query_aliased_prequoted(&sql, &self.alias),
105+
_ => templates.query_aliased(&sql, &self.alias),
106+
}
95107
}
96108
}
97109

rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/references_builder.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ impl ReferencesBuilder {
272272
}
273273
SingleSource::Cube(_) => None,
274274
SingleSource::TableReference(_, schema) => schema.resolve_member_reference(member),
275+
// Opaque pre-rendered SQL: members are referenced via the alias as
276+
// literals in the ON/projection SQL, not resolved against a schema.
277+
SingleSource::RawSubquerySql(_) => None,
275278
};
276279
column_name.map(|col| QualifiedColumnName::new(Some(source.alias.clone()), col))
277280
}

rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/logical_join.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::super::{LogicalNodeProcessor, ProcessableNode, PushDownBuilderContext};
22
use crate::logical_plan::LogicalJoin;
3-
use crate::physical_plan::{From, JoinBuilder, JoinCondition};
3+
use crate::physical_plan::{From, JoinBuilder, JoinCondition, SingleSource};
44
use crate::physical_plan_builder::PhysicalPlanBuilder;
55
use crate::planner::SqlJoinCondition;
66
use cubenativeutils::CubeError;
@@ -38,6 +38,7 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> {
3838
let root = logical_join.root().clone().unwrap().cube().clone();
3939
if logical_join.joins().is_empty()
4040
&& logical_join.dimension_subqueries().is_empty()
41+
&& logical_join.subquery_joins().is_empty()
4142
&& multi_stage_dimension.is_none()
4243
{
4344
Ok(From::new_from_cube(
@@ -61,6 +62,7 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> {
6162
context,
6263
)?;
6364
}
65+
6466
for join in logical_join.joins().iter() {
6567
join_builder.left_join_cube(
6668
join.cube().cube().clone(),
@@ -83,13 +85,33 @@ impl<'a> LogicalNodeProcessor<'a, LogicalJoin> for LogicalJoinProcessor<'a> {
8385
)?;
8486
}
8587
}
88+
8689
if let Some(multi_stage_dimension) = &multi_stage_dimension {
8790
self.builder.add_multistage_dimension_join(
8891
multi_stage_dimension,
8992
&mut join_builder,
9093
&context,
9194
)?;
9295
}
96+
97+
for subquery_join in logical_join.subquery_joins().iter() {
98+
let source = SingleSource::RawSubquerySql(subquery_join.sql.clone());
99+
let on = JoinCondition::new_base_join(SqlJoinCondition::try_new(
100+
subquery_join.on_sql.clone(),
101+
)?);
102+
103+
if subquery_join.join_type.eq_ignore_ascii_case("INNER") {
104+
join_builder.inner_join_source(source, subquery_join.alias.clone(), on);
105+
} else if subquery_join.join_type.eq_ignore_ascii_case("LEFT") {
106+
join_builder.left_join_source(source, subquery_join.alias.clone(), on);
107+
} else {
108+
return Err(CubeError::user(format!(
109+
"Unsupported join type '{}' for sub-query join, expected INNER or LEFT",
110+
subquery_join.join_type
111+
)));
112+
}
113+
}
114+
93115
Ok(From::new_from_join(join_builder.build()))
94116
}
95117
}

0 commit comments

Comments
 (0)