// 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, }; use crate::api::operator::anyelement_query_input_opoid; use crate::postgres::customscan::builders::custom_path::RestrictInfoType; use crate::postgres::customscan::datafusion::explain::format_expr_for_explain; use crate::postgres::customscan::datafusion::translator::PredicateTranslator; use crate::postgres::customscan::pullup::resolve_fast_field; use crate::postgres::customscan::qual_inspect::{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 join_clause, &mut multi_table_predicate_clauses, )?; join_clause.plan = new_plan; if extra.is_null() { return Ok((join_clause, multi_table_predicate_clauses)); } let restrictlist = (*extra).restrictlist; if restrictlist.is_null() { return Ok((join_clause, multi_table_predicate_clauses)); } let search_op = anyelement_query_input_opoid(); let restrict_infos = PgList::::from_pg(restrictlist); // 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 restrict_infos.iter_ptr() { if ri.is_null() || (*ri).clause.is_null() { continue; } let clause = (*ri).clause; 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 join_clause, &mut multi_table_predicate_clauses, ) { expr_trees.push(expr); } else { return Err(format!( "Failed to transform search predicate into expression tree: {}", format_expr_for_explain(clause.cast()).as_str() )); } } else if other_cond_set.contains(&(ri as usize)) { // This is a top-level heap condition (cross-relation, no search operator) // Only accept if all referenced columns are fast fields if !all_vars_are_fast_fields_recursive(clause.cast(), sources) { return Err(format!( "Multi-table predicate '{}' references non-fast-field columns", format_expr_for_explain(clause.cast()) )); } // Check if the predicate can be translated to DataFusion if !PredicateTranslator::can_translate(sources, clause.cast()) { return Err(format!( "Multi-table predicate '{}' cannot be executed by DataFusion (unsupported operator or type)", format_expr_for_explain(clause.cast()) )); } // Create a MultiTablePredicate leaf node let description = format_expr_for_explain(clause.cast()); let predicate_idx = join_clause .add_multi_table_predicate(description, multi_table_predicate_clauses.len()); multi_table_predicate_clauses.push(clause); expr_trees.push(JoinLevelExpr::MultiTablePredicate { predicate_idx }); } } // 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 with search predicates into a JoinLevelExpr. /// /// - For single-table sub-trees with search predicates: extract as a Predicate leaf /// - For cross-relation sub-trees without search predicates: extract as a MultiTablePredicate leaf /// - For BoolExpr (AND/OR/NOT): recursively transform children /// /// Also collects heap condition clause pointers into `multi_table_predicate_clauses` for adding /// to custom_exprs during plan_custom_path. #[allow(clippy::too_many_arguments)] pub unsafe fn transform_to_search_expr( root: *mut pg_sys::PlannerInfo, node: *mut pg_sys::Node, sources: &[&JoinSource], join_clause: &mut JoinCSClause, 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, join_clause, 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); } } // 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. if has_search_op && rtis.len() == 1 && referenced_source_indices.len() == 1 { 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(predicate_idx) = extract_single_table_predicate(root, rti, &base_info, node, join_clause) { return Some(JoinLevelExpr::SingleTablePredicate { plan_position, predicate_idx, }); } } return None; } // If this is a cross-relation expression WITHOUT search predicate, create MultiTablePredicate if !has_search_op && referenced_source_indices.len() > 1 { if !all_vars_are_fast_fields_recursive(node, sources) { return None; } let translator = PredicateTranslator::new(sources); translator.translate(node)?; let description = format_expr_for_explain(node); let predicate_idx = join_clause.add_multi_table_predicate(description, multi_table_predicate_clauses.len()); multi_table_predicate_clauses.push(node as *mut pg_sys::Expr); return Some(JoinLevelExpr::MultiTablePredicate { predicate_idx }); } // 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, join_clause, 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, join_clause, 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 and add it to the join clause. /// Returns the index of the predicate in join_level_predicates, or None if extraction fails. pub unsafe fn extract_single_table_predicate( root: *mut pg_sys::PlannerInfo, rti: pg_sys::Index, side: &ScanInfo, expr: *mut pg_sys::Node, join_clause: &mut JoinCSClause, ) -> 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); let idx = join_clause.add_join_level_predicate(rti, indexrelid, heaprelid, query); Some(idx) } /// 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, join_clause: &mut JoinCSClause, 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, join_clause, 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, join_clause, multi_table_predicate_clauses, )?; let right = lower_absorbed_search_clauses( root, right, join_clause, 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, join_clause, 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, }))) } } } /// `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], join_clause: &mut JoinCSClause, 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, join_clause, multi_table_predicate_clauses, ) .ok_or_else(|| { format!( "Failed to lower absorbed search clause: {}", format_expr_for_explain(clause.cast()).as_str() ) }) }) .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], ) -> 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 { return false; } } true }