Skip to content

Commit 13a7cde

Browse files
committed
fix(planner): correct join non-equi predicates and unsupported join types
Fold all non-equi ON predicates with AND instead of silently dropping all but the first, which caused queries with compound ON clauses to produce wrong results. Return explicit errors for NATURAL JOIN and implicit cross-joins (no ON/USING clause) rather than silently succeeding with empty join keys. Add sql_expr_to_bridge_expr_qualified and expr_filter_qualified for join contexts where merged documents use table-qualified field names, and wire the join condition through serialize_join_filters so non-equi ON predicates are evaluated against merged rows alongside WHERE filters.
1 parent 58071af commit 13a7cde

3 files changed

Lines changed: 77 additions & 21 deletions

File tree

nodedb-sql/src/planner/join.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,16 @@ fn extract_join_constraint(constraint: &ast::JoinConstraint) -> Result<JoinConst
204204
let cond = if non_equi.is_empty() {
205205
None
206206
} else {
207-
Some(convert_expr(non_equi.first().unwrap())?)
207+
// Fold ALL non-equi predicates with AND — not just the first.
208+
let mut combined = convert_expr(&non_equi[0])?;
209+
for pred in &non_equi[1..] {
210+
combined = SqlExpr::BinaryOp {
211+
left: Box::new(combined),
212+
op: crate::types::BinaryOp::And,
213+
right: Box::new(convert_expr(pred)?),
214+
};
215+
}
216+
Some(combined)
208217
};
209218
Ok((keys, cond))
210219
}
@@ -218,8 +227,12 @@ fn extract_join_constraint(constraint: &ast::JoinConstraint) -> Result<JoinConst
218227
.collect();
219228
Ok((keys, None))
220229
}
221-
ast::JoinConstraint::Natural => Ok((Vec::new(), None)),
222-
ast::JoinConstraint::None => Ok((Vec::new(), None)),
230+
ast::JoinConstraint::Natural => Err(crate::error::SqlError::Unsupported {
231+
detail: "NATURAL JOIN is not supported; use explicit ON or USING clause".into(),
232+
}),
233+
ast::JoinConstraint::None => Err(crate::error::SqlError::Unsupported {
234+
detail: "implicit cross join (no ON/USING clause) is not supported".into(),
235+
}),
223236
}
224237
}
225238

nodedb/src/control/planner/sql_plan_convert/expr.rs

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,36 @@ use super::value::sql_value_to_nodedb_value;
66

77
/// Convert a `nodedb_sql::types::SqlExpr` (parser AST) to a
88
/// `nodedb_query::expr::SqlExpr` (bridge evaluation type).
9+
///
10+
/// Column references use the **bare** name (no table qualifier) for
11+
/// single-collection evaluation contexts (WHERE, CHECK, GENERATED).
12+
/// For join contexts where the merged document uses qualified keys
13+
/// (`"t1.col"`), use [`sql_expr_to_bridge_expr_qualified`] instead.
914
pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eval::SqlExpr {
15+
convert_expr_inner(expr, false)
16+
}
17+
18+
/// Like [`sql_expr_to_bridge_expr`] but qualifies column references
19+
/// with their table name (`t.col` → `"t.col"`) for join merged docs.
20+
pub(super) fn sql_expr_to_bridge_expr_qualified(
21+
expr: &SqlExpr,
22+
) -> crate::bridge::expr_eval::SqlExpr {
23+
convert_expr_inner(expr, true)
24+
}
25+
26+
fn convert_expr_inner(expr: &SqlExpr, qualify: bool) -> crate::bridge::expr_eval::SqlExpr {
1027
use crate::bridge::expr_eval::SqlExpr as BExpr;
1128
match expr {
12-
SqlExpr::Column { name, .. } => BExpr::Column(name.clone()),
29+
SqlExpr::Column { table, name } => {
30+
if qualify {
31+
BExpr::Column(nodedb_sql::planner::qualified_name(table.as_deref(), name))
32+
} else {
33+
BExpr::Column(name.clone())
34+
}
35+
}
1336
SqlExpr::Literal(v) => BExpr::Literal(sql_value_to_nodedb_value(v)),
1437
SqlExpr::BinaryOp { left, op, right } => BExpr::BinaryOp {
15-
left: Box::new(sql_expr_to_bridge_expr(left)),
38+
left: Box::new(convert_expr_inner(left, qualify)),
1639
op: match op {
1740
nodedb_sql::types::BinaryOp::Add => crate::bridge::expr_eval::BinaryOp::Add,
1841
nodedb_sql::types::BinaryOp::Sub => crate::bridge::expr_eval::BinaryOp::Sub,
@@ -29,11 +52,14 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
2952
nodedb_sql::types::BinaryOp::Or => crate::bridge::expr_eval::BinaryOp::Or,
3053
nodedb_sql::types::BinaryOp::Concat => crate::bridge::expr_eval::BinaryOp::Concat,
3154
},
32-
right: Box::new(sql_expr_to_bridge_expr(right)),
55+
right: Box::new(convert_expr_inner(right, qualify)),
3356
},
3457
SqlExpr::Function { name, args, .. } => BExpr::Function {
3558
name: name.clone(),
36-
args: args.iter().map(sql_expr_to_bridge_expr).collect(),
59+
args: args
60+
.iter()
61+
.map(|a| convert_expr_inner(a, qualify))
62+
.collect(),
3763
},
3864
SqlExpr::Case {
3965
operand,
@@ -42,14 +68,19 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
4268
} => BExpr::Case {
4369
operand: operand
4470
.as_ref()
45-
.map(|e| Box::new(sql_expr_to_bridge_expr(e))),
71+
.map(|e| Box::new(convert_expr_inner(e, qualify))),
4672
when_thens: when_then
4773
.iter()
48-
.map(|(w, t)| (sql_expr_to_bridge_expr(w), sql_expr_to_bridge_expr(t)))
74+
.map(|(w, t)| {
75+
(
76+
convert_expr_inner(w, qualify),
77+
convert_expr_inner(t, qualify),
78+
)
79+
})
4980
.collect(),
5081
else_expr: else_expr
5182
.as_ref()
52-
.map(|e| Box::new(sql_expr_to_bridge_expr(e))),
83+
.map(|e| Box::new(convert_expr_inner(e, qualify))),
5384
},
5485
SqlExpr::Cast { expr, to_type } => {
5586
let cast_type = match to_type.to_uppercase().as_str() {
@@ -63,18 +94,18 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
6394
_ => crate::bridge::expr_eval::CastType::String,
6495
};
6596
BExpr::Cast {
66-
expr: Box::new(sql_expr_to_bridge_expr(expr)),
97+
expr: Box::new(convert_expr_inner(expr, qualify)),
6798
to_type: cast_type,
6899
}
69100
}
70101
SqlExpr::Wildcard => BExpr::Column("*".into()),
71102

72103
// NOT e / -e → evaluator's Negate (handles both bool and numeric).
73-
SqlExpr::UnaryOp { expr, .. } => BExpr::Negate(Box::new(sql_expr_to_bridge_expr(expr))),
104+
SqlExpr::UnaryOp { expr, .. } => BExpr::Negate(Box::new(convert_expr_inner(expr, qualify))),
74105

75106
// `e IS NULL` / `e IS NOT NULL` — direct passthrough.
76107
SqlExpr::IsNull { expr, negated } => BExpr::IsNull {
77-
expr: Box::new(sql_expr_to_bridge_expr(expr)),
108+
expr: Box::new(convert_expr_inner(expr, qualify)),
78109
negated: *negated,
79110
},
80111

@@ -87,9 +118,9 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
87118
high,
88119
negated,
89120
} => {
90-
let e = sql_expr_to_bridge_expr(expr);
91-
let l = sql_expr_to_bridge_expr(low);
92-
let h = sql_expr_to_bridge_expr(high);
121+
let e = convert_expr_inner(expr, qualify);
122+
let l = convert_expr_inner(low, qualify);
123+
let h = convert_expr_inner(high, qualify);
93124
if *negated {
94125
let lt = BExpr::BinaryOp {
95126
left: Box::new(e.clone()),
@@ -134,7 +165,7 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
134165
list,
135166
negated,
136167
} => {
137-
let target = sql_expr_to_bridge_expr(expr);
168+
let target = convert_expr_inner(expr, qualify);
138169
if list.is_empty() {
139170
// Empty list: `e IN ()` = false, `e NOT IN ()` = true.
140171
return BExpr::Literal(nodedb_types::Value::Bool(*negated));
@@ -157,7 +188,7 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
157188
.map(|item| BExpr::BinaryOp {
158189
left: Box::new(target.clone()),
159190
op: eq_op,
160-
right: Box::new(sql_expr_to_bridge_expr(item)),
191+
right: Box::new(convert_expr_inner(item, qualify)),
161192
})
162193
.reduce(|acc, next| BExpr::BinaryOp {
163194
left: Box::new(acc),
@@ -178,8 +209,8 @@ pub(super) fn sql_expr_to_bridge_expr(expr: &SqlExpr) -> crate::bridge::expr_eva
178209
let call = BExpr::Function {
179210
name: "like".into(),
180211
args: vec![
181-
sql_expr_to_bridge_expr(expr),
182-
sql_expr_to_bridge_expr(pattern),
212+
convert_expr_inner(expr, qualify),
213+
convert_expr_inner(pattern, qualify),
183214
],
184215
};
185216
if *negated {

nodedb/src/control/planner/sql_plan_convert/filter.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ fn filter_to_scan_filters(expr: &FilterExpr) -> Vec<nodedb_query::scan_filter::S
122122
/// Build a `ScanFilter` carrying a full expression predicate. Used whenever
123123
/// the planner cannot reduce the WHERE expression to a simple
124124
/// `(field, op, value)` tuple.
125-
fn expr_filter(expr: &SqlExpr) -> nodedb_query::scan_filter::ScanFilter {
125+
pub(super) fn expr_filter(expr: &SqlExpr) -> nodedb_query::scan_filter::ScanFilter {
126126
nodedb_query::scan_filter::ScanFilter {
127127
field: String::new(),
128128
op: nodedb_query::scan_filter::FilterOp::Expr,
@@ -132,6 +132,18 @@ fn expr_filter(expr: &SqlExpr) -> nodedb_query::scan_filter::ScanFilter {
132132
}
133133
}
134134

135+
/// Like [`expr_filter`] but qualifies column references with table names
136+
/// for evaluation against join-merged documents.
137+
pub(super) fn expr_filter_qualified(expr: &SqlExpr) -> nodedb_query::scan_filter::ScanFilter {
138+
nodedb_query::scan_filter::ScanFilter {
139+
field: String::new(),
140+
op: nodedb_query::scan_filter::FilterOp::Expr,
141+
value: nodedb_types::Value::Null,
142+
clauses: Vec::new(),
143+
expr: Some(super::expr::sql_expr_to_bridge_expr_qualified(expr)),
144+
}
145+
}
146+
135147
/// Convert a raw `SqlExpr` (from WHERE clause) to a `ScanFilter` list.
136148
///
137149
/// Tries to produce simple, field-indexed filters for common cases (direct

0 commit comments

Comments
 (0)