// Copyright (c) 2023-2026 ParadeDB, Inc. // // This file is part of ParadeDB - Postgres for Search and Analytics // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //! Predicate extraction functions for JoinScan. //! //! See the [JoinScan README](README.md) for the full architecture overview. //! //! This module handles the transformation of PostgreSQL expressions containing //! search predicates into `JoinLevelExpr` trees that can be evaluated //! during join execution. It supports: //! //! - Single-table search predicates (converted to Tantivy queries) //! - Cross-relation heap conditions (evaluated by PostgreSQL) //! - Boolean expression trees (AND/OR/NOT) use super::build::{ FilterNode, JoinCSClause, JoinLevelExpr, JoinNode, JoinSource, RelNode, ScanInfo, UnnestNode, }; use crate::api::operator::anyelement_query_input_opoid; use crate::postgres::customscan::builders::custom_path::RestrictInfoType; use crate::postgres::customscan::datafusion::translator::PredicateTranslator; use crate::postgres::customscan::pullup::resolve_fast_field; use crate::postgres::customscan::qual_inspect::{ contains_exec_param, extract_quals, PlannerContext, QualExtractState, }; use crate::postgres::rel::PgSearchRelation; use crate::postgres::rel_get_bm25_index; use crate::postgres::utils::{expr_collect_rtis, expr_collect_vars, expr_contains_any_operator}; use crate::query::SearchQueryInput; use pgrx::{pg_sys, PgList}; /// Extract join-level conditions from the restrict list and transform them into /// a `JoinLevelExpr` tree. /// /// This function processes the join's restrict list to identify: /// - Search predicates (@@@ operator): transformed into Predicate nodes /// - Cross-relation conditions: transformed into MultiTablePredicate nodes /// - Boolean expressions: recursively processed to preserve structure /// /// `JoinNode.absorbed_search_clauses` carries `@@@` `RestrictInfo`s parked /// during sub-join reconstruction. This is the first point a `JoinCSClause` /// exists to receive the interned predicates, so we drain them here before /// the regular `extra->restrictlist` walk. /// /// Returns the updated JoinCSClause and a list of heap condition clause pointers /// (in the same order as multi_table_predicates in the clause) for adding to custom_exprs. pub unsafe fn extract_join_level_conditions( root: *mut pg_sys::PlannerInfo, extra: *mut pg_sys::JoinPathExtraData, sources: &[&JoinSource], other_conditions: &[*mut pg_sys::RestrictInfo], mut join_clause: JoinCSClause, ) -> Result<(JoinCSClause, Vec<*mut pg_sys::Expr>), String> { let mut multi_table_predicate_clauses: Vec<*mut pg_sys::Expr> = Vec::new(); if sources.is_empty() { return Ok((join_clause, multi_table_predicate_clauses)); } // The absorbed-clause walk is independent of `extra`: it mutates // `join_clause.plan` and `join_clause.join_level_predicates` from each // sub-join's `joinrestrictinfo`. PG places each clause at its lowest // applicable join, and the absorbed path only runs on Inner sub-joins // (the Inner-only gate in `collect_join_sources_join_rel`), so in // practice the two passes process disjoint clause sets. let new_plan = lower_absorbed_search_clauses( root, std::mem::take(&mut join_clause.plan), &mut multi_table_predicate_clauses, )?; join_clause.plan = new_plan; let search_op = anyelement_query_input_opoid(); let mut all_restrict_infos: Vec<*mut pg_sys::RestrictInfo> = Vec::new(); if !extra.is_null() && !(*extra).restrictlist.is_null() { let restrict_infos = PgList::::from_pg((*extra).restrictlist); for ri in restrict_infos.iter_ptr() { if !ri.is_null() && !(*ri).clause.is_null() { all_restrict_infos.push(ri); } } } for ri in other_conditions { if !ri.is_null() && !(**ri).clause.is_null() && !all_restrict_infos.contains(ri) { all_restrict_infos.push(*ri); } } // Collect all expressions into the expression tree let mut expr_trees: Vec = Vec::new(); // Track which RestrictInfos are heap conditions (by pointer) for index lookup let other_cond_set: crate::api::HashSet = other_conditions.iter().map(|&ri| ri as usize).collect(); for ri in &all_restrict_infos { let clause = (**ri).clause; let rtis = expr_collect_rtis(clause.cast()); // All clauses in `all_restrict_infos` originate from `extra.restrictlist` (or // `post_join_conditions` extracted from it). PostgreSQL places clauses in a joinrel's // restrictlist ONLY if they can and must be evaluated at this join level; clauses that // could be evaluated at lower levels or single base scans are never included here. // In chained outer joins, single-table WHERE clauses on nullable relations (e.g. // `WHERE orders.age IS NULL` in `users LEFT (products LEFT orders)`) cannot be pushed // into the sub-join and are delayed to the outer join level. Therefore, any clause // whose referenced RTIs are all contained within this plan must be absorbed here. if !rtis.iter().all(|rti| join_clause.plan.contains_rti(*rti)) { let formatted = crate::postgres::deparse::deparse_planner_expr_or_raw(root, clause.cast()); return Err(format!( "Join predicate '{}' references relation outside the join plan", formatted )); } let already_present = multi_table_predicate_clauses.iter().any(|c| { std::ptr::eq((*c).cast::(), clause.cast()) || pg_sys::equal((*c).cast(), clause.cast()) }); if already_present { continue; } let has_search_op = expr_contains_any_operator(clause.cast(), &[search_op]); if has_search_op { if let Some(expr) = transform_to_search_expr( root, clause.cast(), sources, &mut multi_table_predicate_clauses, ) { expr_trees.push(expr); } else { let formatted = crate::postgres::deparse::deparse_planner_expr_or_raw(root, clause.cast()); return Err(format!( "Failed to transform search predicate into expression tree: {}", formatted )); } } else if other_cond_set.contains(&(*ri as usize)) { // This is a top-level heap condition (cross-relation or unnest filter, no search operator) // Only accept if all referenced columns are fast fields if !all_vars_are_fast_fields_recursive(clause.cast(), sources, Some(&join_clause.plan)) { let formatted = crate::postgres::deparse::deparse_planner_expr_or_raw(root, clause.cast()); return Err(format!( "Multi-table predicate '{}' references non-columnar columns", formatted )); } // Check if the predicate can be translated to DataFusion if !PredicateTranslator::can_translate( Some(root), sources, clause.cast(), Some(&join_clause.plan), ) { let formatted = crate::postgres::deparse::deparse_planner_expr_or_raw(root, clause.cast()); return Err(format!( "Multi-table predicate '{}' cannot be executed by DataFusion (unsupported operator or type)", formatted )); } // Create a MultiTablePredicate leaf node let pg_node_string = crate::postgres::deparse::node_to_string_owned(clause.cast()); multi_table_predicate_clauses.push(clause); expr_trees.push(JoinLevelExpr::MultiTablePredicate { predicate: Box::new( crate::postgres::customscan::joinscan::build::MultiTablePredicateInfo { pg_node_string, }, ), }); } } // Fallback: parse-tree `(*jointree).quals` - PG sometimes leaves cross-table // predicates here when outer joins (e.g., LATERAL unnest) prevent them from // being pushed into joinrestrictinfo. let parse = (*root).parse; if !parse.is_null() && !(*parse).jointree.is_null() && !(*(*parse).jointree).quals.is_null() { let mut conjuncts = Vec::new(); crate::postgres::customscan::qual_inspect::collect_implicit_and_conjuncts( (*(*parse).jointree).quals, &mut conjuncts, ); let valid_rtis: Vec = sources.iter().map(|s| s.scan_info.heap_rti).collect(); for conjunct in conjuncts { let rtis = expr_collect_rtis(conjunct); // Only process cross-table predicates whose referenced RTIs are all in `sources` // and span across both sides of this join level. if rtis.len() > 1 && rtis.iter().all(|rti| join_clause.plan.contains_rti(*rti)) && join_clause.plan.spans_both_sides(&rtis) { // Check if this conjunct was already extracted as an equi-key if super::build::try_extract_equi_key(conjunct.cast(), &valid_rtis).is_some() { continue; } // Check if this conjunct is already present in all_restrict_infos or multi_table_predicate_clauses let already_present = all_restrict_infos.iter().any(|ri| { std::ptr::eq((**ri).clause.cast::(), conjunct) || pg_sys::equal((**ri).clause.cast(), conjunct.cast()) }) || multi_table_predicate_clauses.iter().any(|c| { std::ptr::eq((*c).cast::(), conjunct) || pg_sys::equal((*c).cast(), conjunct.cast()) }); if already_present { continue; } let has_search_op = expr_contains_any_operator(conjunct.cast(), &[search_op]); if has_search_op { if let Some(expr) = transform_to_search_expr( root, conjunct.cast(), sources, &mut multi_table_predicate_clauses, ) { expr_trees.push(expr); } else { let formatted = crate::postgres::deparse::deparse_planner_expr(root, conjunct.cast()) .unwrap_or_else(|| { crate::postgres::deparse::node_to_string_without_context( conjunct.cast(), ) }); return Err(format!( "Failed to transform search predicate into expression tree: {}", formatted )); } } else { if !all_vars_are_fast_fields_recursive( conjunct.cast(), sources, Some(&join_clause.plan), ) { let formatted = crate::postgres::deparse::deparse_planner_expr(root, conjunct.cast()) .unwrap_or_else(|| { crate::postgres::deparse::node_to_string_without_context( conjunct.cast(), ) }); return Err(format!( "Multi-table predicate '{}' references non-fast-field columns", formatted )); } if !PredicateTranslator::can_translate( Some(root), sources, conjunct.cast(), Some(&join_clause.plan), ) { let formatted = crate::postgres::deparse::deparse_planner_expr(root, conjunct.cast()) .unwrap_or_else(|| { crate::postgres::deparse::node_to_string_without_context( conjunct.cast(), ) }); return Err(format!( "Multi-table predicate '{}' cannot be executed by DataFusion (unsupported operator or type)", formatted )); } let pg_node_string = { let node_str = pg_sys::nodeToString(conjunct.cast()); std::ffi::CStr::from_ptr(node_str) .to_string_lossy() .into_owned() }; multi_table_predicate_clauses.push(conjunct.cast()); expr_trees.push(JoinLevelExpr::MultiTablePredicate { predicate: Box::new( crate::postgres::customscan::joinscan::build::MultiTablePredicateInfo { pg_node_string, }, ), }); } } } } // Combine all expressions with AND if !expr_trees.is_empty() { let final_expr = if expr_trees.len() == 1 { expr_trees.pop().unwrap() } else { JoinLevelExpr::And(expr_trees) }; join_clause = join_clause.with_join_level_expr(final_expr); } join_clause.assign_tagged_queries(); Ok((join_clause, multi_table_predicate_clauses)) } /// Recursively transform a PostgreSQL expression tree into a `JoinLevelExpr`. /// /// Handles: /// - Single-table search predicates: extracted into a single Tantivy query (preserving NOT/AND/OR) /// - Cross-relation sub-trees without search predicates: extracted as a MultiTablePredicate leaf /// (in the same order as multi_table_predicates in the clause) for adding to custom_exprs /// - Cross-relation boolean expressions (AND/OR/NOT): recursively preserved in the `JoinLevelExpr` tree pub unsafe fn transform_to_search_expr( root: *mut pg_sys::PlannerInfo, node: *mut pg_sys::Node, sources: &[&JoinSource], multi_table_predicate_clauses: &mut Vec<*mut pg_sys::Expr>, ) -> Option { if node.is_null() { return None; } // A List is an implicit conjunction container, not one expression. It // must be decomposed before relation classification: collecting RTIs from // the container can otherwise group unrelated single-table predicates into // one apparent cross-table predicate. let node_type = (*node).type_; if node_type == pg_sys::NodeTag::T_List { let list = PgList::::from_pg(node as *mut pg_sys::List); let mut children = Vec::new(); for item in list.iter_ptr() { let child_expr = transform_to_search_expr(root, item, sources, multi_table_predicate_clauses)?; children.push(child_expr); } return if children.is_empty() { None } else if children.len() == 1 { Some(children.pop().unwrap()) } else { Some(JoinLevelExpr::And(children)) }; } let search_op = anyelement_query_input_opoid(); let has_search_op = expr_contains_any_operator(node, &[search_op]); // Check which tables this expression references let rtis = expr_collect_rtis(node); let mut referenced_source_indices = Vec::new(); for (i, source) in sources.iter().enumerate() { if rtis.iter().any(|&rti| source.contains_rti(rti)) { referenced_source_indices.push(i); } } /// Check if an expression consists purely of search operators (`search_op`) /// connected by boolean operations (AND, OR, NOT). Expressions containing /// non-search predicates (such as `NullTest`, comparisons, etc.) return false. unsafe fn is_pure_search_expr(mut node: *mut pg_sys::Node, search_op: pg_sys::Oid) -> bool { if node.is_null() { return false; } node = super::build::strip_node_wrappers(node); if node.is_null() { return false; } match (*node).type_ { pg_sys::NodeTag::T_OpExpr => { let opexpr = node as *mut pg_sys::OpExpr; (*opexpr).opno == search_op } pg_sys::NodeTag::T_BoolExpr => { let boolexpr = node as *mut pg_sys::BoolExpr; let args = PgList::::from_pg((*boolexpr).args); !args.is_empty() && args .iter_ptr() .all(|arg| is_pure_search_expr(arg, search_op)) } _ => false, } } // If this is a single-table expression with search predicate, extract as a single // Tantivy search predicate so that table-local negation (with NULL-preserving exists guards), // conjunctions, and disjunctions are evaluated natively by Tantivy. // Mixed expressions containing non-search sub-predicates (e.g. `IS NULL`) are bypassed // so they decompose into DataFusion boolean expressions post-join, preserving outer join null semantics. if has_search_op && rtis.len() == 1 && referenced_source_indices.len() == 1 && is_pure_search_expr(node, search_op) { let rti = *rtis.iter().next().unwrap(); let source = &sources[referenced_source_indices[0]]; let plan_position = source.plan_position; // Extract the Tantivy query for this expression if let Some(base_info) = find_base_info_recursive(source, rti) { if let Some(pred) = extract_single_table_predicate(root, rti, &base_info, node) { return Some(JoinLevelExpr::SingleTablePredicate { plan_position, predicate: Box::new(pred), }); } } return None; } // If this is a post-join expression WITHOUT search predicate, create MultiTablePredicate if !has_search_op && !referenced_source_indices.is_empty() { if !all_vars_are_fast_fields_recursive(node, sources, None) { return None; } let translator = PredicateTranslator::new(sources).with_planner_info(root); translator.translate(node)?; let pg_node_string = crate::postgres::deparse::node_to_string_owned(node.cast()); multi_table_predicate_clauses.push(node as *mut pg_sys::Expr); return Some(JoinLevelExpr::MultiTablePredicate { predicate: Box::new( crate::postgres::customscan::joinscan::build::MultiTablePredicateInfo { pg_node_string, }, ), }); } // If this is a cross-table BoolExpr, preserve its boolean structure (AND, OR, NOT) // in JoinLevelExpr so it can be translated into DataFusion's boolean expressions. if node_type == pg_sys::NodeTag::T_BoolExpr { let boolexpr = node as *mut pg_sys::BoolExpr; let boolop = (*boolexpr).boolop; let args = PgList::::from_pg((*boolexpr).args); match boolop { pg_sys::BoolExprType::AND_EXPR | pg_sys::BoolExprType::OR_EXPR => { let mut children = Vec::new(); for arg in args.iter_ptr() { let child_expr = transform_to_search_expr( root, arg, sources, multi_table_predicate_clauses, )?; children.push(child_expr); } if children.is_empty() { None } else if children.len() == 1 { Some(children.pop().unwrap()) } else if boolop == pg_sys::BoolExprType::AND_EXPR { Some(JoinLevelExpr::And(children)) } else { Some(JoinLevelExpr::Or(children)) } } pg_sys::BoolExprType::NOT_EXPR => { if let Some(arg) = args.iter_ptr().next() { if let Some(child_expr) = transform_to_search_expr(root, arg, sources, multi_table_predicate_clauses) { return Some(JoinLevelExpr::Not(Box::new(child_expr))); } } None } _ => None, } } else { None } } pub unsafe fn find_base_info_recursive( source: &JoinSource, rti: pg_sys::Index, ) -> Option { if source.contains_rti(rti) { Some(source.scan_info.clone()) } else { None } } /// Extract a single-table predicate from an expression. pub unsafe fn extract_single_table_predicate( root: *mut pg_sys::PlannerInfo, rti: pg_sys::Index, side: &ScanInfo, expr: *mut pg_sys::Node, ) -> Option { let indexrelid = side.indexrelid; let heaprelid = side.heaprelid; let (_, bm25_idx) = rel_get_bm25_index(heaprelid)?; // Create a RestrictInfo wrapping the expression for extract_quals let mut ri_list = PgList::::new(); let fake_ri = pg_sys::palloc0(std::mem::size_of::()) as *mut pg_sys::RestrictInfo; (*fake_ri).type_ = pg_sys::NodeTag::T_RestrictInfo; (*fake_ri).clause = expr.cast(); ri_list.push(fake_ri); let context = PlannerContext::from_planner(root); let mut state = QualExtractState::default(); let qual = extract_quals( &context, rti, ri_list.as_ptr().cast(), RestrictInfoType::BaseRelation, &bm25_idx, false, &mut state, false, )?; let query = SearchQueryInput::from(&qual); Some( crate::postgres::customscan::joinscan::build::JoinLevelSearchPredicate { rti, indexrelid, heaprelid, query, }, ) } /// Sub-join reconstruction stashes `@@@` `RestrictInfo`s onto /// `JoinNode.absorbed_search_clauses` without lowering them, because no /// `JoinCSClause` exists yet to receive interned `plan_position`s. Once one /// does, walking the tree converts each entry into a `RelNode::Filter` /// wrapping the absorbing `JoinNode`. pub(super) unsafe fn lower_absorbed_search_clauses( root: *mut pg_sys::PlannerInfo, node: RelNode, multi_table_predicate_clauses: &mut Vec<*mut pg_sys::Expr>, ) -> Result { match node { RelNode::Scan(s) => Ok(RelNode::Scan(s)), RelNode::Filter(f) => { let FilterNode { input, predicate } = *f; let input = lower_absorbed_search_clauses(root, input, multi_table_predicate_clauses)?; Ok(RelNode::Filter(Box::new(FilterNode { input, predicate }))) } RelNode::Join(j) => { let JoinNode { join_type, left, right, equi_keys, filter, subplan_id, absorbed_search_clauses, } = *j; let left = lower_absorbed_search_clauses(root, left, multi_table_predicate_clauses)?; let right = lower_absorbed_search_clauses(root, right, multi_table_predicate_clauses)?; if absorbed_search_clauses.is_empty() { return Ok(RelNode::Join(Box::new(JoinNode { join_type, left, right, equi_keys, filter, subplan_id, absorbed_search_clauses: Vec::new(), }))); } // PG anchors `RestrictInfo`s against RTIs from the sub-tree, so // resolve against everything reachable below this join. let mut sub_sources = left.sources(); sub_sources.extend(right.sources()); let predicate = build_absorbed_filter( root, &sub_sources, &absorbed_search_clauses, multi_table_predicate_clauses, )?; Ok(RelNode::Filter(Box::new(FilterNode { input: RelNode::Join(Box::new(JoinNode { join_type, left, right, equi_keys, filter, subplan_id, absorbed_search_clauses: Vec::new(), })), predicate, }))) } RelNode::Unnest(u) => { let UnnestNode { input, unnest_info, absorbed_clauses, } = *u; let input = lower_absorbed_search_clauses(root, input, multi_table_predicate_clauses)?; let unnest_node = RelNode::Unnest(Box::new(UnnestNode { input, unnest_info, absorbed_clauses: Vec::new(), })); if absorbed_clauses.is_empty() { return Ok(unnest_node); } let sub_sources = unnest_node.sources(); let predicate = build_absorbed_filter( root, &sub_sources, &absorbed_clauses, multi_table_predicate_clauses, )?; Ok(RelNode::Filter(Box::new(FilterNode { input: unnest_node, predicate, }))) } } } /// `absorbed` was populated from a live `joinrestrictinfo` earlier in the /// same planning pass, so every entry should still translate. We error /// rather than skip so a future refactor that drops a clause on the floor /// blows up the test suite instead of producing wrong rows. unsafe fn build_absorbed_filter( root: *mut pg_sys::PlannerInfo, sub_sources: &[&JoinSource], absorbed: &[*mut pg_sys::RestrictInfo], multi_table_predicate_clauses: &mut Vec<*mut pg_sys::Expr>, ) -> Result { let expr_trees: Vec = absorbed .iter() .copied() .map(|ri| { if ri.is_null() { return Err("absorbed search clause is a null RestrictInfo".to_string()); } let clause = (*ri).clause; if clause.is_null() { return Err("absorbed search clause has a null clause".to_string()); } transform_to_search_expr( root, clause.cast(), sub_sources, multi_table_predicate_clauses, ) .ok_or_else(|| { let formatted = crate::postgres::deparse::deparse_planner_expr(root, clause.cast()) .unwrap_or_else(|| { crate::postgres::deparse::node_to_string_without_context(clause.cast()) }); format!("Failed to lower absorbed search clause: {}", formatted) }) }) .collect::>()?; // Caller guarantees `absorbed` is non-empty; `collect` either yielded // N entries or short-circuited with `Err`. An empty result here would // otherwise lower to `And(vec![])`, which evaluates to TRUE and // silently wipes the WHERE. match expr_trees.len() { 0 => Err("absorbed clause set lowered to empty expr tree".to_string()), 1 => Ok(expr_trees.into_iter().next().unwrap()), _ => Ok(JoinLevelExpr::And(expr_trees)), } } /// Check if all Var references in an expression are fast fields. pub unsafe fn all_vars_are_fast_fields_recursive( node: *mut pg_sys::Node, sources: &[&JoinSource], plan: Option<&crate::postgres::customscan::joinscan::build::RelNode>, ) -> bool { let vars = expr_collect_vars(node, false); for var_ref in vars { let mut source_found = false; for source in sources { if source.contains_rti(var_ref.rti) { if let Some(base_info) = find_base_info_recursive(source, var_ref.rti) { let heaprel = PgSearchRelation::open(base_info.heaprelid); let indexrel = PgSearchRelation::open(base_info.indexrelid); if resolve_fast_field(var_ref.attno as i32, &heaprel.tuple_desc(), &indexrel) .is_none() { return false; } } else { return false; } source_found = true; break; } } if !source_found { if let Some(plan) = plan { if let Some(unnest_info) = plan.find_lateral_unnest(var_ref.rti) { if sources .iter() .any(|s| s.contains_rti(unnest_info.source_rti.0)) { source_found = true; } } } } if !source_found { return false; } } true } /// The outcome of validating and absorbing conditions for a join node. pub struct ResolvedJoinConditions { /// Serialized expression absorbed into `JoinNode.filter` to be evaluated /// during the join (e.g. non-equi join conditions, outer-join ON conditions, /// or semi/anti join conditions). pub filter: Option, /// Conditions that could not be absorbed into the join filter but are safe /// to evaluate post-join (e.g. search operators `@@@` or WHERE-clause predicates /// on inner/outer joins). pub post_join_conditions: Vec<*mut pg_sys::RestrictInfo>, } /// Validates join conditions and absorbs translatable non-equi conditions /// into `JoinNode.filter`. /// /// Returns `Ok(ResolvedJoinConditions)` if all join conditions are legal for the join type, /// or `Err(JoinDeclineReason)` if any condition violates join semantics: /// - Outer join ON conditions (`is_pushed_down == false`) that cannot be absorbed into `filter` /// cannot be evaluated post-join without dropping null-extended rows. /// - Semi and Anti join conditions cannot be evaluated post-join because the inner relation's /// columns are not projected by the join. /// - Keyless joins require at least one condition absorbed into `filter`. pub unsafe fn resolve_join_conditions( root: *mut pg_sys::PlannerInfo, sources: &[&JoinSource], equi_keys: &[super::build::JoinKeyPair], other_conditions: &[*mut pg_sys::RestrictInfo], jointype: pg_sys::JoinType::Type, ) -> Result { let is_outer = matches!( jointype, pg_sys::JoinType::JOIN_LEFT | pg_sys::JoinType::JOIN_RIGHT | pg_sys::JoinType::JOIN_FULL ); let is_semi_or_anti = matches!( jointype, pg_sys::JoinType::JOIN_SEMI | pg_sys::JoinType::JOIN_ANTI ); #[cfg(any(feature = "pg16", feature = "pg17", feature = "pg18"))] let is_semi_or_anti = is_semi_or_anti || jointype == pg_sys::JoinType::JOIN_RIGHT_ANTI; #[cfg(feature = "pg18")] let is_semi_or_anti = is_semi_or_anti || jointype == pg_sys::JoinType::JOIN_RIGHT_SEMI; let search_op = anyelement_query_input_opoid(); let mut absorbed_clauses: Vec<*mut pg_sys::Node> = Vec::new(); let mut unabsorbed: Vec<*mut pg_sys::RestrictInfo> = Vec::with_capacity(other_conditions.len()); for &ri in other_conditions { let clause = (*ri).clause; // Skip `@@@` (and any search ops): search clauses pass through to // `extract_join_level_conditions`, where `transform_to_search_expr` handles them. if !clause.is_null() && expr_contains_any_operator(clause.cast(), &[search_op]) { unabsorbed.push(ri); continue; } // For outer joins, only absorb ON-clause conditions (is_pushed_down == false); // WHERE-clause conditions (is_pushed_down == true) stay as post-join filters. if is_outer && (*ri).is_pushed_down { unabsorbed.push(ri); continue; } if clause.is_null() || contains_exec_param(clause.cast()) || !all_vars_are_fast_fields_recursive(clause.cast(), sources, None) || !PredicateTranslator::can_translate(Some(root), sources, clause.cast(), None) { unabsorbed.push(ri); continue; } absorbed_clauses.push(clause.cast()); } let filter = match absorbed_clauses.len() { 0 => None, _ => { // Combine multiple absorbed clauses into a single AND at // the PG node level so we serialize one expression tree. let combined_node: *mut pg_sys::Node = if absorbed_clauses.len() == 1 { absorbed_clauses[0] } else { let mut list = PgList::::new(); for n in &absorbed_clauses { list.push((*n).cast()); } pg_sys::make_andclause(list.into_pg()).cast() }; let pg_node_string = crate::postgres::deparse::node_to_string_owned(combined_node.cast()); let input_vars = crate::postgres::customscan::joinscan::collect_input_vars(combined_node); Some(JoinLevelExpr::PgExpression { pg_node_string, input_vars, }) } }; // Identify unabsorbed conditions that cannot be evaluated post-join: // - Outer joins: ON conditions (is_pushed_down == false) must be evaluated during the join. // - Semi / Anti joins: inner columns are not projected post-join, so all conditions must be evaluated during the join. // - Keyless joins: when equi_keys is empty, at least one condition must be absorbed as a join filter. let (illegal_residuals, context) = if is_outer { ( unabsorbed .iter() .copied() .filter(|&ri| !(*ri).is_pushed_down) .collect::>(), "outer join ON clauses", ) } else if is_semi_or_anti { (unabsorbed.clone(), "semi/anti join conditions") } else if equi_keys.is_empty() && !other_conditions.is_empty() && filter.is_none() { (unabsorbed.clone(), "join conditions") } else { (Vec::new(), "") }; let must_decline = !illegal_residuals.is_empty() || (equi_keys.is_empty() && (is_outer || is_semi_or_anti) && filter.is_none()); if must_decline { if (is_outer || is_semi_or_anti) && equi_keys.is_empty() && filter.is_none() { return Err(super::JoinDeclineReason::new( "JoinScan not used: non-inner joins require at least one join condition", )); } if illegal_residuals.iter().any(|&ri| { let clause = (*ri).clause; !clause.is_null() && crate::postgres::utils::expr_contains_any_operator(clause.cast(), &[search_op]) }) { return Err(super::JoinDeclineReason::new(format!( "JoinScan not used: search operators in {context} are not supported" ))); } if illegal_residuals.iter().any(|&ri| { let clause = (*ri).clause; !clause.is_null() && crate::postgres::customscan::collation_semantics::expr_has_unsupported_collation( clause.cast(), ) }) { return Err(super::JoinDeclineReason::new( "JoinScan not used: join conditions on a nondeterministic collation are not supported", )); } return Err(super::JoinDeclineReason::new( "JoinScan not used: join conditions must reference columnar indexed fields", )); } Ok(ResolvedJoinConditions { filter, post_join_conditions: unabsorbed, }) }