laminar_sql/planner/predicate_split/
mod.rs1#[allow(clippy::disallowed_types)] use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23
24use datafusion::logical_expr::logical_plan::LogicalPlan;
25use datafusion::logical_expr::{
26 BinaryExpr, Expr, Extension, Filter, Operator as DfOperator, UserDefinedLogicalNodeCore,
27};
28use datafusion_common::tree_node::Transformed;
29use datafusion_common::Result;
30use datafusion_optimizer::optimizer::{ApplyOrder, OptimizerConfig, OptimizerRule};
31
32use crate::datafusion::lookup_join::{LookupJoinNode, LookupJoinType};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PredicateClass {
41 LookupOnly,
43 StreamOnly,
45 CrossReference,
47 Constant,
49}
50
51#[derive(Debug)]
57pub struct PredicateClassifier {
58 lookup_columns: HashSet<String>,
60 stream_columns: HashSet<String>,
62 lookup_qualified: HashSet<String>,
64 stream_qualified: HashSet<String>,
66}
67
68impl PredicateClassifier {
69 #[must_use]
75 pub fn new(
76 lookup_columns: HashSet<String>,
77 stream_columns: HashSet<String>,
78 lookup_alias: Option<&str>,
79 stream_alias: Option<&str>,
80 ) -> Self {
81 let mut lookup_qualified = HashSet::new();
82 let mut stream_qualified = HashSet::new();
83
84 if let Some(alias) = lookup_alias {
85 for col in &lookup_columns {
86 lookup_qualified.insert(format!("{alias}.{col}"));
87 }
88 }
89 if let Some(alias) = stream_alias {
90 for col in &stream_columns {
91 stream_qualified.insert(format!("{alias}.{col}"));
92 }
93 }
94
95 Self {
96 lookup_columns,
97 stream_columns,
98 lookup_qualified,
99 stream_qualified,
100 }
101 }
102
103 #[must_use]
105 pub fn classify(&self, expr: &Expr) -> PredicateClass {
106 let mut has_lookup = false;
107 let mut has_stream = false;
108 self.walk_columns(expr, &mut has_lookup, &mut has_stream);
109
110 match (has_lookup, has_stream) {
111 (true, false) => PredicateClass::LookupOnly,
112 (false, true) => PredicateClass::StreamOnly,
113 (true, true) => PredicateClass::CrossReference,
114 (false, false) => PredicateClass::Constant,
115 }
116 }
117
118 fn walk_columns(&self, expr: &Expr, has_lookup: &mut bool, has_stream: &mut bool) {
120 match expr {
121 Expr::Column(col) => {
122 if let Some(relation) = &col.relation {
124 let qualified = format!("{}.{}", relation, col.name);
125 if self.lookup_qualified.contains(&qualified) {
126 *has_lookup = true;
127 return;
128 }
129 if self.stream_qualified.contains(&qualified) {
130 *has_stream = true;
131 return;
132 }
133 }
134 if self.lookup_columns.contains(&col.name) {
136 *has_lookup = true;
137 }
138 if self.stream_columns.contains(&col.name) {
139 *has_stream = true;
140 }
141 }
142 Expr::BinaryExpr(BinaryExpr { left, right, .. }) => {
143 self.walk_columns(left, has_lookup, has_stream);
144 self.walk_columns(right, has_lookup, has_stream);
145 }
146 Expr::Not(inner)
147 | Expr::IsNull(inner)
148 | Expr::IsNotNull(inner)
149 | Expr::Negative(inner)
150 | Expr::Cast(datafusion::logical_expr::Cast { expr: inner, .. })
151 | Expr::TryCast(datafusion::logical_expr::TryCast { expr: inner, .. }) => {
152 self.walk_columns(inner, has_lookup, has_stream);
153 }
154 Expr::Between(between) => {
155 self.walk_columns(&between.expr, has_lookup, has_stream);
156 self.walk_columns(&between.low, has_lookup, has_stream);
157 self.walk_columns(&between.high, has_lookup, has_stream);
158 }
159 Expr::InList(in_list) => {
160 self.walk_columns(&in_list.expr, has_lookup, has_stream);
161 for item in &in_list.list {
162 self.walk_columns(item, has_lookup, has_stream);
163 }
164 }
165 Expr::ScalarFunction(func) => {
166 for arg in &func.args {
167 self.walk_columns(arg, has_lookup, has_stream);
168 }
169 }
170 Expr::Like(like) => {
171 self.walk_columns(&like.expr, has_lookup, has_stream);
172 self.walk_columns(&like.pattern, has_lookup, has_stream);
173 }
174 Expr::Case(case) => {
175 if let Some(operand) = &case.expr {
176 self.walk_columns(operand, has_lookup, has_stream);
177 }
178 for (when, then) in &case.when_then_expr {
179 self.walk_columns(when, has_lookup, has_stream);
180 self.walk_columns(then, has_lookup, has_stream);
181 }
182 if let Some(else_expr) = &case.else_expr {
183 self.walk_columns(else_expr, has_lookup, has_stream);
184 }
185 }
186 Expr::Literal(..) | Expr::Placeholder(_) => {}
188 _ => {
190 *has_lookup = true;
191 *has_stream = true;
192 }
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum PlanPushdownMode {
204 Full,
206 KeyOnly,
208 None,
210}
211
212#[derive(Debug, Clone)]
214pub struct PlanSourceCapabilities {
215 pub pushdown_mode: PlanPushdownMode,
217 pub eq_columns: HashSet<String>,
219 pub range_columns: HashSet<String>,
221 pub in_columns: HashSet<String>,
223 pub supports_null_check: bool,
225}
226
227impl Default for PlanSourceCapabilities {
228 fn default() -> Self {
229 Self {
230 pushdown_mode: PlanPushdownMode::None,
231 eq_columns: HashSet::new(),
232 range_columns: HashSet::new(),
233 in_columns: HashSet::new(),
234 supports_null_check: false,
235 }
236 }
237}
238
239#[derive(Debug, Default)]
241pub struct SourceCapabilitiesRegistry {
242 capabilities: HashMap<String, PlanSourceCapabilities>,
243}
244
245impl SourceCapabilitiesRegistry {
246 pub fn register(&mut self, table_name: String, caps: PlanSourceCapabilities) {
248 self.capabilities.insert(table_name, caps);
249 }
250
251 #[must_use]
253 pub fn get(&self, table_name: &str) -> Option<&PlanSourceCapabilities> {
254 self.capabilities.get(table_name)
255 }
256}
257
258#[must_use]
267pub fn split_conjunction(expr: &Expr) -> Vec<Expr> {
268 match expr {
269 Expr::BinaryExpr(BinaryExpr {
270 left,
271 op: DfOperator::And,
272 right,
273 }) => {
274 let mut parts = split_conjunction(left);
275 parts.extend(split_conjunction(right));
276 parts
277 }
278 other => vec![other.clone()],
279 }
280}
281
282#[derive(Debug)]
296pub struct PredicateSplitterRule {
297 capabilities: SourceCapabilitiesRegistry,
299}
300
301impl PredicateSplitterRule {
302 #[must_use]
304 pub fn new(capabilities: SourceCapabilitiesRegistry) -> Self {
305 Self { capabilities }
306 }
307
308 fn split_for_node(
312 &self,
313 node: &LookupJoinNode,
314 filter_predicates: &[Expr],
315 ) -> (Vec<Expr>, Vec<Expr>) {
316 let lookup_columns: HashSet<String> = node
318 .lookup_schema()
319 .fields()
320 .iter()
321 .map(|f| f.name().clone())
322 .collect();
323
324 let input_schema = node.inputs()[0].schema();
325 let stream_columns: HashSet<String> = input_schema
326 .fields()
327 .iter()
328 .map(|f| f.name().clone())
329 .collect();
330
331 let classifier = PredicateClassifier::new(
332 lookup_columns,
333 stream_columns,
334 node.lookup_alias(),
335 node.stream_alias(),
336 );
337
338 let caps = self.capabilities.get(node.lookup_table_name());
339 let pushdown_disabled = caps.is_none_or(|c| c.pushdown_mode == PlanPushdownMode::None);
340
341 let is_left_outer = node.join_type() == LookupJoinType::LeftOuter;
342
343 let mut pushdown = Vec::new();
344 let mut local = Vec::new();
345
346 let all_predicates = node
348 .pushdown_predicates()
349 .iter()
350 .chain(node.local_predicates().iter())
351 .chain(filter_predicates.iter())
352 .cloned();
353
354 for pred in all_predicates {
355 let class = classifier.classify(&pred);
356
357 let has_not_eq = contains_not_eq(&pred);
359
360 match class {
361 PredicateClass::LookupOnly => {
362 if is_left_outer || pushdown_disabled || has_not_eq {
364 local.push(pred);
365 } else {
366 pushdown.push(pred);
367 }
368 }
369 PredicateClass::StreamOnly
370 | PredicateClass::CrossReference
371 | PredicateClass::Constant => {
372 local.push(pred);
373 }
374 }
375 }
376
377 (pushdown, local)
378 }
379}
380
381fn contains_not_eq(expr: &Expr) -> bool {
383 match expr {
384 Expr::BinaryExpr(BinaryExpr { left, op, right }) => {
385 *op == DfOperator::NotEq || contains_not_eq(left) || contains_not_eq(right)
386 }
387 Expr::Not(inner) => contains_not_eq(inner),
388 _ => false,
389 }
390}
391
392impl OptimizerRule for PredicateSplitterRule {
393 fn name(&self) -> &'static str {
394 "predicate_splitter"
395 }
396
397 fn apply_order(&self) -> Option<ApplyOrder> {
398 Some(ApplyOrder::TopDown)
399 }
400
401 fn rewrite(
402 &self,
403 plan: LogicalPlan,
404 _config: &dyn OptimizerConfig,
405 ) -> Result<Transformed<LogicalPlan>> {
406 if let LogicalPlan::Filter(Filter {
408 predicate, input, ..
409 }) = &plan
410 {
411 if let LogicalPlan::Extension(ext) = input.as_ref() {
412 if let Some(node) = ext.node.as_any().downcast_ref::<LookupJoinNode>() {
413 let filter_preds = split_conjunction(predicate);
414 let (pushdown, local) = self.split_for_node(node, &filter_preds);
415
416 let inputs = node.inputs();
417 let rebuilt = LookupJoinNode::new(
418 inputs[0].clone(),
419 node.lookup_table_name().to_string(),
420 node.lookup_schema().clone(),
421 node.join_keys().to_vec(),
422 node.join_type(),
423 pushdown,
424 node.required_lookup_columns().clone(),
425 UserDefinedLogicalNodeCore::schema(node).clone(),
426 node.metadata().clone(),
427 )
428 .with_local_predicates(local)
429 .with_aliases(
430 node.lookup_alias().map(String::from),
431 node.stream_alias().map(String::from),
432 );
433
434 return Ok(Transformed::yes(LogicalPlan::Extension(Extension {
435 node: Arc::new(rebuilt),
436 })));
437 }
438 }
439 }
440
441 if let LogicalPlan::Extension(ext) = &plan {
443 if let Some(node) = ext.node.as_any().downcast_ref::<LookupJoinNode>() {
444 if !node.pushdown_predicates().is_empty() || !node.local_predicates().is_empty() {
446 let (pushdown, local) = self.split_for_node(node, &[]);
447 let inputs = node.inputs();
448 let rebuilt = LookupJoinNode::new(
449 inputs[0].clone(),
450 node.lookup_table_name().to_string(),
451 node.lookup_schema().clone(),
452 node.join_keys().to_vec(),
453 node.join_type(),
454 pushdown,
455 node.required_lookup_columns().clone(),
456 UserDefinedLogicalNodeCore::schema(node).clone(),
457 node.metadata().clone(),
458 )
459 .with_local_predicates(local)
460 .with_aliases(
461 node.lookup_alias().map(String::from),
462 node.stream_alias().map(String::from),
463 );
464
465 return Ok(Transformed::yes(LogicalPlan::Extension(Extension {
466 node: Arc::new(rebuilt),
467 })));
468 }
469 }
470 }
471
472 Ok(Transformed::no(plan))
473 }
474}
475
476#[cfg(test)]
477mod tests;