// Copyright (c) 2023-2025 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 . mod searchqueryinput; mod text; use crate::api::{fieldname_typoid, FieldName}; use crate::index::mvcc::MvccSatisfies; use crate::index::reader::index::SearchIndexReader; use crate::nodecast; use crate::postgres::expression::{find_expr_attnum, PG_SEARCH_PREFIX}; use crate::postgres::utils::locate_bm25_index_from_heaprel; use crate::postgres::var::find_var_relation; use crate::query::SearchQueryInput; use pgrx::callconv::{BoxRet, FcInfo}; use pgrx::datum::Datum; use pgrx::pg_sys::{FuncExpr, NodeTag, OpExpr}; use pgrx::pgrx_sql_entity_graph::metadata::{ ArgumentError, Returns, ReturnsError, SqlMapping, SqlTranslatable, }; use pgrx::*; use std::ptr::NonNull; #[derive(Debug)] #[repr(transparent)] pub struct ReturnedNodePointer(pub Option>); unsafe impl BoxRet for ReturnedNodePointer { unsafe fn box_into<'fcx>(self, fcinfo: &mut FcInfo<'fcx>) -> Datum<'fcx> { self.0 .map(|nonnull| { let datum = pg_sys::Datum::from(nonnull.as_ptr()); fcinfo.return_raw_datum(datum) }) .unwrap_or_else(Datum::null) } } unsafe impl SqlTranslatable for ReturnedNodePointer { fn argument_sql() -> Result { Ok(SqlMapping::As("internal".into())) } fn return_sql() -> Result { Ok(Returns::One(SqlMapping::As("internal".into()))) } } pub fn parse_with_field_procoid() -> pg_sys::Oid { unsafe { direct_function_call::( pg_sys::regprocedurein, // NB: the SQL signature here needs to match our Rust implementation &[c"paradedb.parse_with_field(paradedb.fieldname, text, bool, bool)".into_datum()], ) .expect("the `paradedb.parse_with_field(paradedb.fieldname, text, bool, bool)` function should exist") } } pub fn anyelement_query_input_procoid() -> pg_sys::Oid { unsafe { direct_function_call::( pg_sys::regprocedurein, &[c"paradedb.search_with_query_input(anyelement, paradedb.searchqueryinput)".into_datum()], ) .expect("the `paradedb.search_with_query_input(anyelement, paradedb.searchqueryinput) function should exist") } } pub fn anyelement_text_procoid() -> pg_sys::Oid { unsafe { direct_function_call::( pg_sys::regprocedurein, &[c"paradedb.search_with_text(anyelement, text)".into_datum()], ) .expect("the `paradedb.search_with_text(anyelement, text) function should exist") } } pub fn anyelement_text_opoid() -> pg_sys::Oid { unsafe { direct_function_call::( pg_sys::regoperatorin, &[c"@@@(anyelement, text)".into_datum()], ) .expect("the `@@@(anyelement, text)` operator should exist") } } pub fn anyelement_query_input_opoid() -> pg_sys::Oid { unsafe { direct_function_call::( pg_sys::regoperatorin, &[c"@@@(anyelement, paradedb.searchqueryinput)".into_datum()], ) .expect("the `@@@(anyelement, paradedb.searchqueryinput)` operator should exist") } } pub fn searchqueryinput_typoid() -> pg_sys::Oid { unsafe { let oid = direct_function_call::( pg_sys::regtypein, &[c"paradedb.SearchQueryInput".into_datum()], ) .expect("type `paradedb.SearchQueryInput` should exist"); if oid == pg_sys::Oid::INVALID { panic!("type `paradedb.SearchQueryInput` should exist"); } oid } } pub(crate) fn estimate_selectivity( indexrel: &PgRelation, search_query_input: &SearchQueryInput, ) -> Option { let reltuples = indexrel .heap_relation() .expect("indexrel should be an index") .reltuples() .unwrap_or(1.0) as f64; if !reltuples.is_normal() || reltuples.is_sign_negative() { // we can't estimate against a non-normal or negative estimate of heap tuples return None; } let search_reader = SearchIndexReader::open(indexrel, MvccSatisfies::Snapshot) .expect("estimate_selectivity: should be able to open a SearchIndexReader"); let estimate = search_reader.estimate_docs(search_query_input).unwrap_or(1) as f64; let mut selectivity = estimate / reltuples; if selectivity > 1.0 { selectivity = 1.0; } Some(selectivity) } unsafe fn make_search_query_input_opexpr_node( srs: *mut pg_sys::SupportRequestSimplify, input_args: &mut PgList, lhs: *mut pg_sys::Node, query: Option, parse_with_field: Option<(*mut pg_sys::Node, Option)>, opoid: pg_sys::Oid, procoid: pg_sys::Oid, ) -> ReturnedNodePointer { let (relid, _nodeattno, targetlist) = find_node_relation(lhs, (*srs).root); if relid == pg_sys::Oid::INVALID { panic!("could not determine relation for node"); } // we need to use what should be the only `USING bm25` index on the table let heaprel = PgRelation::open(relid); let indexrel = locate_bm25_index_from_heaprel(&heaprel).unwrap_or_else(|| { panic!( "relation `{}.{}` must have a `USING bm25` index", heaprel.namespace(), heaprel.name() ) }); let keys = &(*indexrel.rd_index).indkey; let keys = keys.values.as_slice(keys.dim1 as usize); let tupdesc = PgTupleDesc::from_pg_unchecked(indexrel.rd_att); let att = tupdesc .get(0) .unwrap_or_else(|| panic!("attribute `{}` not found", keys[0])); if (*lhs).type_ == NodeTag::T_Var { let var = nodecast!(Var, T_Var, lhs).expect("lhs is not a Var"); if let Some(targetlist) = &targetlist { // if we have a targetlist, find the first field of the index definition in it -- its location // in the target list becomes the var's attno let mut found = false; for (i, te) in targetlist.iter_ptr().enumerate() { if te.is_null() { continue; } if (*te).resorigcol == keys[0] { (*var).varattno = (i + 1) as _; (*var).varattnosyn = (*var).varattno; found = true; break; } } if !found { panic!("index's first column is not in the var's targetlist"); } } else { // the Var must look like the first attribute from the index definition (*var).varattno = keys[0]; (*var).varattnosyn = (*var).varattno; } // the Var must also assume the type of the first attribute from the index definition, // regardless of where we found the Var (*var).vartype = att.atttypid; (*var).vartypmod = att.atttypmod; (*var).varcollid = att.attcollation; } // we're about to fabricate a new pg_sys::OpExpr node to return // that represents the `@@@(anyelement, paradedb.searchqueryinput)` operator let mut newopexpr = PgBox::::alloc_node(pg_sys::NodeTag::T_OpExpr); if let Some(query) = query { // In case a sequential scan gets triggered, we need a way to pass the index oid // to the scan function. It otherwise will not know which index to use. let wrapped_query = SearchQueryInput::WithIndex { oid: indexrel.oid(), query: Box::new(query), }; // create a new pg_sys::Const node let search_query_input_const = pg_sys::makeConst( searchqueryinput_typoid(), -1, pg_sys::Oid::INVALID, -1, wrapped_query.into_datum().unwrap(), false, false, ); // and assign it to the original argument list input_args.replace_ptr(1, search_query_input_const.cast()); newopexpr.opno = anyelement_query_input_opoid(); newopexpr.opfuncid = anyelement_query_input_procoid(); } else if let Some((rhs, attname)) = parse_with_field { // rewrite the rhs to be a function call to our `paradedb.parse_with_field(...)` function let mut parse_with_field_args = PgList::::new(); if let Some(attname) = attname { parse_with_field_args.push( pg_sys::makeConst( fieldname_typoid(), -1, pg_sys::Oid::INVALID, -1, attname.into_datum().unwrap(), false, false, ) .cast(), ); } else { parse_with_field_args.push(lhs); } parse_with_field_args.push(rhs.cast()); parse_with_field_args.push( pg_sys::makeConst( pg_sys::BOOLOID, -1, pg_sys::Oid::INVALID, size_of::() as _, pg_sys::Datum::from(false), false, true, ) .cast(), ); parse_with_field_args.push( pg_sys::makeConst( pg_sys::BOOLOID, -1, pg_sys::Oid::INVALID, size_of::() as _, pg_sys::Datum::from(false), false, true, ) .cast(), ); let funcexpr = pg_sys::makeFuncExpr( parse_with_field_procoid(), searchqueryinput_typoid(), parse_with_field_args.into_pg(), pg_sys::Oid::INVALID, pg_sys::DEFAULT_COLLATION_OID, pg_sys::CoercionForm::COERCE_EXPLICIT_CALL, ); input_args.replace_ptr(1, funcexpr.cast()); newopexpr.opno = anyelement_query_input_opoid(); newopexpr.opfuncid = anyelement_query_input_procoid(); } else { newopexpr.opno = opoid; newopexpr.opfuncid = procoid; } newopexpr.opresulttype = pg_sys::BOOLOID; newopexpr.opcollid = pg_sys::Oid::INVALID; newopexpr.inputcollid = pg_sys::DEFAULT_COLLATION_OID; newopexpr.location = (*(*srs).fcall).location; // then assign that list to our new OpExpr node newopexpr.args = input_args.as_ptr(); let newopexpr = newopexpr.into_pg(); ReturnedNodePointer(NonNull::new(newopexpr.cast())) } /// Given a [`pg_sys::Node`] and a [`pg_sys::PlannerInfo`], attempt to find the relation Oid that /// is referenced by the node. /// /// It's possible the returned Oid will be [`pg_sys::Oid::INVALID`] if the Node doesn't eventually /// come from a relation. In case of a FuncExpr we stop on the first relation found. /// /// The returned [`pg_sys::AttrNumber`] is the physical attribute number in the relation the Node /// is from. pub unsafe fn find_node_relation( node: *mut pg_sys::Node, root: *mut pg_sys::PlannerInfo, ) -> ( pg_sys::Oid, pg_sys::AttrNumber, Option>, ) { // If the Node is var, immediately return it: otherwise examine the arguments of whatever type // it is. let args = match (*node).type_ { NodeTag::T_Var => return find_var_relation(node.cast(), root), NodeTag::T_FuncExpr => { let funcexpr: *mut FuncExpr = node.cast(); PgList::::from_pg((*funcexpr).args) } NodeTag::T_OpExpr => { let opexpr: *mut OpExpr = node.cast(); PgList::::from_pg((*opexpr).args) } _ => return (pg_sys::Oid::INVALID, 0, None), }; args.iter_ptr() .filter_map(|arg| match (*arg).type_ { NodeTag::T_FuncExpr | NodeTag::T_OpExpr | NodeTag::T_Var => { Some(find_node_relation(arg, root)) } _ => None, }) .reduce(|(acc_oid, acc_attno, acc_tl), (oid, _attno, _tl)| { if acc_oid != oid { panic!("expressions cannot contain multiple relations"); } (acc_oid, acc_attno, acc_tl) }) .unwrap_or_else(|| (pg_sys::Oid::INVALID, 0, None)) } /// Given a [`pg_sys::PlannerInfo`] and a [`pg_sys::Var`] from it, figure out the name of the `Var` /// /// Returns the heap relation [`pg_sys::Oid`] that contains the `Var` along with its name. pub unsafe fn attname_from_var( root: *mut pg_sys::PlannerInfo, var: *mut pg_sys::Var, ) -> (pg_sys::Oid, Option) { let (heaprelid, varattno, _) = find_var_relation(var, root); if (*var).varattno == 0 { return (heaprelid, None); } let heaprel = PgRelation::open(heaprelid); let tupdesc = heaprel.tuple_desc(); let attname = if varattno == pg_sys::SelfItemPointerAttributeNumber as pg_sys::AttrNumber { Some("ctid".into()) } else { tupdesc .get(varattno as usize - 1) .map(|attribute| attribute.name().into()) }; (heaprelid, attname) } /// Given a [`pg_sys::PlannerInfo`] and a [`pg_sys::Node`] from it, figure out the name of the `Node`. /// It supports `FuncExpr` and `Var` nodes. Note that for the heap relation, the `Var` must be /// the first argument of the `FuncExpr`. /// This function requires the node to be related to a `bm25` index, otherwise it will panic. /// /// Returns the heap relation [`pg_sys::Oid`] that contains the `Node` along with its name. pub unsafe fn fieldname_from_node( root: *mut pg_sys::PlannerInfo, node: *mut pg_sys::Node, ) -> Option<(pg_sys::Oid, Option)> { match (*node).type_ { NodeTag::T_FuncExpr | NodeTag::T_OpExpr => { // We expect the funcexpr/opexpr to contain the var of the field name we're looking for let (heaprelid, _, _) = find_node_relation(node, root); if heaprelid == pg_sys::Oid::INVALID { panic!("could not find heap relation for node"); } let heaprel = PgRelation::open(heaprelid); let indexrel = locate_bm25_index_from_heaprel(&heaprel) .expect("could not find bm25 index for heaprelid"); let attnum = find_expr_attnum(&indexrel, node)?; let expression_str = format!("{}{}", PG_SEARCH_PREFIX, attnum).into(); Some((heaprelid, Some(expression_str))) } NodeTag::T_Var => { let var = nodecast!(Var, T_Var, node).expect("node is not a Var"); let (oid, attname) = attname_from_var(root, var); Some((oid, attname)) } _ => None, } } extension_sql!( r#" ALTER FUNCTION paradedb.search_with_text SUPPORT paradedb.text_support; ALTER FUNCTION paradedb.search_with_query_input SUPPORT paradedb.query_input_support; CREATE OPERATOR pg_catalog.@@@ ( PROCEDURE = search_with_text, LEFTARG = anyelement, RIGHTARG = text, RESTRICT = text_restrict ); CREATE OPERATOR pg_catalog.@@@ ( PROCEDURE = search_with_query_input, LEFTARG = anyelement, RIGHTARG = paradedb.searchqueryinput, RESTRICT = query_input_restrict ); CREATE OPERATOR CLASS anyelement_bm25_ops DEFAULT FOR TYPE anyelement USING bm25 AS OPERATOR 1 pg_catalog.@@@(anyelement, text), /* for querying with a tantivy-compatible text query */ OPERATOR 2 pg_catalog.@@@(anyelement, paradedb.searchqueryinput), /* for querying with a paradedb.searchqueryinput structure */ STORAGE anyelement; "#, name = "bm25_ops_anyelement_operator", requires = [ // for using plain text on the rhs text::search_with_text, text::text_restrict, text::text_support, // for using SearchQueryInput on the rhs searchqueryinput::search_with_query_input, searchqueryinput::query_input_restrict, searchqueryinput::query_input_support, ] );