// 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 .
use pgrx::datum::RangeBound;
use pgrx::{iter::TableIterator, *};
use crate::api::{FieldName, HashMap};
use crate::index::mvcc::MvccSatisfies;
use crate::index::reader::index::SearchIndexReader;
use crate::postgres::index::IndexKind;
use crate::postgres::types::TantivyValue;
use crate::query::{SearchQueryInput, TermInput};
use crate::schema::AnyEnum;
use crate::schema::IndexRecordOption;
use serde::Serialize;
use std::fmt::{Display, Formatter};
use std::ops::Bound;
use tantivy::schema::{FieldType, OwnedValue, Value};
#[allow(clippy::type_complexity)]
#[pg_extern]
pub fn schema(
index: PgRelation,
) -> TableIterator<
'static,
(
name!(name, String),
name!(field_type, String),
name!(stored, bool),
name!(indexed, bool),
name!(fast, bool),
name!(fieldnorms, bool),
name!(expand_dots, Option),
name!(tokenizer, Option),
name!(record, Option),
name!(normalizer, Option),
),
> {
// # Safety
//
// Lock the index relation until the end of this function so it is not dropped or
// altered while we are reading it.
//
// Because we accept a PgRelation above, we have confidence that Postgres has already
// validated the existence of the relation. We are safe calling the function below as
// long we do not pass pg_sys::NoLock without any other locking mechanism of our own.
let index = unsafe { PgRelation::with_lock(index.oid(), pg_sys::AccessShareLock as _) };
// We only consider the first partition for the purposes of computing a schema.
let index = IndexKind::for_index(index)
.unwrap()
.partitions()
.next()
.expect("expected at least one partition of the index");
let search_reader = SearchIndexReader::open(&index, MvccSatisfies::Snapshot)
.expect("could not open search index reader");
let schema = search_reader.schema();
let mut field_rows = Vec::new();
for (_, field_entry) in schema.fields() {
let (field_type, tokenizer, record, normalizer, expand_dots) =
match field_entry.field_type() {
FieldType::I64(_) => ("I64".to_string(), None, None, None, None),
FieldType::U64(_) => ("U64".to_string(), None, None, None, None),
FieldType::F64(_) => ("F64".to_string(), None, None, None, None),
FieldType::Bool(_) => ("Bool".to_string(), None, None, None, None),
FieldType::Str(text_options) => {
let indexing_options = text_options.get_indexing_options();
let tokenizer = indexing_options.map(|opt| opt.tokenizer().to_string());
let record = indexing_options
.map(|opt| IndexRecordOption::from(opt.index_option()).to_string());
let normalizer = text_options
.get_fast_field_tokenizer_name()
.map(|s| s.to_string());
("Str".to_string(), tokenizer, record, normalizer, None)
}
FieldType::JsonObject(json_options) => {
let indexing_options = json_options.get_text_indexing_options();
let tokenizer = indexing_options.map(|opt| opt.tokenizer().to_string());
let record = indexing_options
.map(|opt| IndexRecordOption::from(opt.index_option()).to_string());
let normalizer = json_options
.get_fast_field_tokenizer_name()
.map(|s| s.to_string());
let expand_dots = Some(json_options.is_expand_dots_enabled());
(
"JsonObject".to_string(),
tokenizer,
record,
normalizer,
expand_dots,
)
}
FieldType::Date(_) => ("Date".to_string(), None, None, None, None),
_ => ("Other".to_string(), None, None, None, None),
};
let row = (
field_entry.name().to_string(),
field_type,
field_entry.is_stored(),
field_entry.is_indexed(),
field_entry.is_fast(),
field_entry.has_fieldnorms(),
expand_dots,
tokenizer,
record,
normalizer,
);
field_rows.push(row);
}
// Sort field rows for consistent ordering
field_rows.sort_by_key(|(name, _, _, _, _, _, _, _, _, _)| name.clone());
TableIterator::new(field_rows)
}
#[pg_extern(immutable, parallel_safe)]
pub fn all() -> SearchQueryInput {
SearchQueryInput::All
}
#[pg_extern(name = "boolean", immutable, parallel_safe)]
pub fn boolean_arrays(
must: default!(Vec, "ARRAY[]::searchqueryinput[]"),
should: default!(Vec, "ARRAY[]::searchqueryinput[]"),
must_not: default!(Vec, "ARRAY[]::searchqueryinput[]"),
) -> SearchQueryInput {
SearchQueryInput::Boolean {
must,
should,
must_not,
}
}
#[pg_extern(name = "boolean", immutable, parallel_safe)]
pub fn boolean_singles(
must: default!(Option, "NULL"),
should: default!(Option, "NULL"),
must_not: default!(Option, "NULL"),
) -> SearchQueryInput {
boolean_arrays(
must.map_or(vec![], |v| vec![v]),
should.map_or(vec![], |v| vec![v]),
must_not.map_or(vec![], |v| vec![v]),
)
}
#[pg_extern(immutable, parallel_safe)]
pub fn boost(factor: f32, query: SearchQueryInput) -> SearchQueryInput {
SearchQueryInput::Boost {
query: Box::new(query),
factor,
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn const_score(score: f32, query: SearchQueryInput) -> SearchQueryInput {
SearchQueryInput::ConstScore {
query: Box::new(query),
score,
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn disjunction_max(
disjuncts: Array,
tie_breaker: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::DisjunctionMax {
disjuncts: disjuncts.iter_deny_null().collect(),
tie_breaker,
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn empty() -> SearchQueryInput {
SearchQueryInput::Empty
}
#[pg_extern(immutable, parallel_safe)]
pub fn exists(field: FieldName) -> SearchQueryInput {
SearchQueryInput::Exists { field }
}
// Not clear on whether this query makes sense to support, as only our "key_field" is a fast
// field... and the user can just use SQL to select a range. We'll keep the implementation here
// for now, but we should remove when we decide definitively that we don't need this.
#[allow(unused)]
pub fn fast_field_range_weight(
field: FieldName,
range: default!(Option>, "NULL"),
) -> SearchQueryInput {
match range.expect("`range` argument is required").into_inner() {
None => SearchQueryInput::FastFieldRangeWeight {
field,
lower_bound: Bound::Included(0),
upper_bound: Bound::Excluded(0),
},
Some((lower, upper)) => SearchQueryInput::FastFieldRangeWeight {
field,
lower_bound: match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(n as u64),
RangeBound::Exclusive(n) => Bound::Excluded(n as u64),
},
upper_bound: match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(n as u64),
RangeBound::Exclusive(n) => Bound::Excluded(n as u64),
},
},
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn fuzzy_term(
field: FieldName,
value: default!(Option, "NULL"),
distance: default!(Option, "NULL"),
transposition_cost_one: default!(Option, "NULL"),
prefix: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::FuzzyTerm {
field,
value: value.expect("`value` argument is required"),
distance: distance.map(|n| n as u8),
transposition_cost_one,
prefix,
}
}
#[pg_extern(name = "match", immutable, parallel_safe)]
pub fn match_query(
field: FieldName,
value: String,
tokenizer: default!(Option, "NULL"),
distance: default!(Option, "NULL"),
transposition_cost_one: default!(Option, "NULL"),
prefix: default!(Option, "NULL"),
conjunction_mode: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::Match {
field,
value,
tokenizer: tokenizer.map(|t| t.0),
distance: distance.map(|n| n as u8),
transposition_cost_one,
prefix,
conjunction_mode,
}
}
#[pg_extern(name = "more_like_this", immutable, parallel_safe)]
pub fn more_like_this_empty() -> SearchQueryInput {
panic!("more_like_this must be called with either document_id or document_fields");
}
#[allow(clippy::too_many_arguments)]
#[pg_extern(name = "more_like_this", immutable, parallel_safe)]
pub fn more_like_this_fields(
document_fields: String,
min_doc_frequency: default!(Option, "NULL"),
max_doc_frequency: default!(Option, "NULL"),
min_term_frequency: default!(Option, "NULL"),
max_query_terms: default!(Option, "NULL"),
min_word_length: default!(Option, "NULL"),
max_word_length: default!(Option, "NULL"),
boost_factor: default!(Option, "NULL"),
stop_words: default!(Option>, "NULL"),
) -> SearchQueryInput {
let document_fields: HashMap =
json5::from_str(&document_fields).expect("could not parse document_fields");
SearchQueryInput::MoreLikeThis {
min_doc_frequency: min_doc_frequency.map(|n| n as u64),
max_doc_frequency: max_doc_frequency.map(|n| n as u64),
min_term_frequency: min_term_frequency.map(|n| n as usize),
max_query_terms: max_query_terms.map(|n| n as usize),
min_word_length: min_word_length.map(|n| n as usize),
max_word_length: max_word_length.map(|n| n as usize),
boost_factor,
stop_words,
document_fields: Some(document_fields.into_iter().collect()),
document_id: None,
}
}
#[allow(clippy::too_many_arguments)]
#[pg_extern(name = "more_like_this", immutable, parallel_safe)]
pub fn more_like_this_id(
document_id: AnyElement,
min_doc_frequency: default!(Option, "NULL"),
max_doc_frequency: default!(Option, "NULL"),
min_term_frequency: default!(Option, "NULL"),
max_query_terms: default!(Option, "NULL"),
min_word_length: default!(Option, "NULL"),
max_word_length: default!(Option, "NULL"),
boost_factor: default!(Option, "NULL"),
stop_words: default!(Option>, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::MoreLikeThis {
min_doc_frequency: min_doc_frequency.map(|n| n as u64),
max_doc_frequency: max_doc_frequency.map(|n| n as u64),
min_term_frequency: min_term_frequency.map(|n| n as usize),
max_query_terms: max_query_terms.map(|n| n as usize),
min_word_length: min_word_length.map(|n| n as usize),
max_word_length: max_word_length.map(|n| n as usize),
boost_factor,
stop_words,
document_fields: None,
document_id: unsafe {
Some(
TantivyValue::try_from_datum(
document_id.datum(),
PgOid::from_untagged(document_id.oid()),
)
.unwrap_or_else(|err| panic!("could not read more_like_this document_id: {err}"))
.0,
)
},
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn parse(
query_string: String,
lenient: default!(Option, "NULL"),
conjunction_mode: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::Parse {
query_string,
lenient,
conjunction_mode,
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn parse_with_field(
field: FieldName,
query_string: String,
lenient: default!(Option, "NULL"),
conjunction_mode: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::ParseWithField {
field,
query_string,
lenient,
conjunction_mode,
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn phrase(
field: FieldName,
phrases: Vec,
slop: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::Phrase {
field,
phrases,
slop: slop.map(|n| n as u32),
}
}
#[pg_extern(immutable, parallel_safe)]
pub fn phrase_prefix(
field: FieldName,
phrases: Vec,
max_expansion: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::PhrasePrefix {
field,
phrases,
max_expansions: max_expansion.map(|n| n as u32),
}
}
#[pg_extern(name = "range", immutable, parallel_safe)]
pub fn range_i32(field: FieldName, range: Range) -> SearchQueryInput {
match range.into_inner() {
None => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::I64(0)),
upper_bound: Bound::Excluded(OwnedValue::I64(0)),
is_datetime: false,
},
Some((lower, upper)) => SearchQueryInput::Range {
field,
lower_bound: match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::I64(n as i64)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::I64(n as i64)),
},
upper_bound: match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::I64(n as i64)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::I64(n as i64)),
},
is_datetime: false,
},
}
}
#[pg_extern(name = "range", immutable, parallel_safe)]
pub fn range_i64(field: FieldName, range: Range) -> SearchQueryInput {
match range.into_inner() {
None => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::I64(0)),
upper_bound: Bound::Excluded(OwnedValue::I64(0)),
is_datetime: false,
},
Some((lower, upper)) => SearchQueryInput::Range {
field,
lower_bound: match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::I64(n)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::I64(n)),
},
upper_bound: match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::I64(n)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::I64(n)),
},
is_datetime: false,
},
}
}
#[pg_extern(name = "range", immutable, parallel_safe)]
pub fn range_numeric(field: FieldName, range: Range) -> SearchQueryInput {
match range.into_inner() {
None => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::F64(0.0)),
upper_bound: Bound::Excluded(OwnedValue::F64(0.0)),
is_datetime: false,
},
Some((lower, upper)) => SearchQueryInput::Range {
field,
lower_bound: match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::F64(
n.try_into().expect("numeric should be a valid f64"),
)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::F64(
n.try_into().expect("numeric should be a valid f64"),
)),
},
upper_bound: match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(OwnedValue::F64(
n.try_into().expect("numeric should be a valid f64"),
)),
RangeBound::Exclusive(n) => Bound::Excluded(OwnedValue::F64(
n.try_into().expect("numeric should be a valid f64"),
)),
},
is_datetime: false,
},
}
}
macro_rules! datetime_range_fn {
($func_name:ident, $value_type:ty) => {
#[pg_extern(name = "range", immutable, parallel_safe)]
pub fn $func_name(field: FieldName, range: Range<$value_type>) -> SearchQueryInput {
match range.into_inner() {
None => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(tantivy::schema::OwnedValue::Date(
tantivy::DateTime::from_timestamp_micros(0),
)),
upper_bound: Bound::Excluded(tantivy::schema::OwnedValue::Date(
tantivy::DateTime::from_timestamp_micros(0),
)),
is_datetime: true,
},
Some((lower, upper)) => SearchQueryInput::Range {
field,
lower_bound: match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(
(&TantivyValue::try_from(n)
.expect("n should be a valid TantivyValue representation")
.tantivy_schema_value())
.as_datetime()
.expect("OwnedValue should be a valid datetime value")
.into(),
),
RangeBound::Exclusive(n) => Bound::Excluded(
(&TantivyValue::try_from(n)
.expect("n should be a valid TantivyValue representation")
.tantivy_schema_value())
.as_datetime()
.expect("OwnedValue should be a valid datetime value")
.into(),
),
},
upper_bound: match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(
(&TantivyValue::try_from(n)
.expect("n should be a valid TantivyValue representation")
.tantivy_schema_value())
.as_datetime()
.expect("OwnedValue should be a valid datetime value")
.into(),
),
RangeBound::Exclusive(n) => Bound::Excluded(
(&TantivyValue::try_from(n)
.expect("n should be a valid TantivyValue representation")
.tantivy_schema_value())
.as_datetime()
.expect("OwnedValue should be a valid datetime value")
.into(),
),
},
is_datetime: true,
},
}
}
};
}
datetime_range_fn!(range_date, pgrx::datum::Date);
datetime_range_fn!(range_timestamp, pgrx::datum::Timestamp);
datetime_range_fn!(range_timestamptz, pgrx::datum::TimestampWithTimeZone);
#[rustfmt::skip]
#[pg_extern(immutable, parallel_safe)]
pub unsafe fn term_with_operator(
field: FieldName,
operator: String,
value: AnyElement,
) -> anyhow::Result {
macro_rules! make_query {
($operator:expr, $field:expr, $eq_func_name:ident, $value_type:ty, $anyelement:expr, $is_datetime:literal) => {
match $operator.as_str() {
"=" => Ok($eq_func_name(Some($field), <$value_type>::from_datum($anyelement.datum(), false))),
"<>" => Ok(
SearchQueryInput::Boolean {
must: vec![SearchQueryInput::All],
should: vec![],
must_not: vec![$eq_func_name(Some($field), <$value_type>::from_datum($anyelement.datum(), false))]
}
),
">" => generic_range_query($field, Bound::Excluded($anyelement), Bound::Unbounded, $is_datetime),
">=" => generic_range_query($field, Bound::Included($anyelement), Bound::Unbounded, $is_datetime),
"<" => generic_range_query($field, Bound::Unbounded, Bound::Excluded($anyelement), $is_datetime),
"<=" => generic_range_query($field, Bound::Unbounded, Bound::Included($anyelement), $is_datetime),
other => panic!("unsupported operator: {other}"),
}
};
}
match value.oid() {
pg_sys::CHAROID => make_query!(operator, field, term_i8, i8, value, false),
pg_sys::INT2OID => make_query!(operator, field, term_i16, i16, value, false),
pg_sys::INT4OID => make_query!(operator, field, term_i32, i32, value, false),
pg_sys::INT8OID => make_query!(operator, field, term_i64, i64, value, false),
pg_sys::FLOAT4OID => make_query!(operator, field, term_f32, f32, value, false),
pg_sys::FLOAT8OID => make_query!(operator, field, term_f64, f64, value, false),
pg_sys::BOOLOID => make_query!(operator, field, term_bool, bool, value, false),
pg_sys::NUMERICOID => make_query!(operator, field, numeric, AnyNumeric, value, false),
pg_sys::TEXTOID => make_query!(operator, field, term_str, String, value, false),
pg_sys::VARCHAROID => make_query!(operator, field, term_str, String, value, false),
pg_sys::UUIDOID => make_query!(operator, field, uuid, pgrx::datum::Uuid, value, false),
pg_sys::DATEOID => make_query!(operator, field, date, pgrx::datum::Date, value, true),
pg_sys::TIMEOID => make_query!(operator, field, time, pgrx::datum::Time, value, true),
pg_sys::TIMETZOID => make_query!(operator, field, time_with_time_zone, pgrx::datum::TimeWithTimeZone, value, true),
pg_sys::TIMESTAMPOID => make_query!(operator, field, timestamp, pgrx::datum::Timestamp, value, true),
pg_sys::TIMESTAMPTZOID => make_query!(operator, field, timestamp_with_time_zone, pgrx::datum::TimestampWithTimeZone, value, true),
other => panic!("unsupported type: {other:?}"),
}
}
unsafe fn generic_range_query(
field: FieldName,
lower: Bound,
upper: Bound,
is_datetime: bool,
) -> anyhow::Result {
let query = match (lower, upper) {
(Bound::Included(s), Bound::Included(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
(Bound::Included(s), Bound::Excluded(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
(Bound::Included(s), Bound::Unbounded) => SearchQueryInput::Range {
field,
lower_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Unbounded,
is_datetime,
},
(Bound::Excluded(s), Bound::Excluded(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
(Bound::Excluded(s), Bound::Included(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
(Bound::Excluded(s), Bound::Unbounded) => SearchQueryInput::Range {
field,
lower_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(s)?)),
upper_bound: Bound::Unbounded,
is_datetime,
},
(Bound::Unbounded, Bound::Unbounded) => SearchQueryInput::Range {
field,
lower_bound: Bound::Unbounded,
upper_bound: Bound::Unbounded,
is_datetime,
},
(Bound::Unbounded, Bound::Included(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Unbounded,
upper_bound: Bound::Included(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
(Bound::Unbounded, Bound::Excluded(e)) => SearchQueryInput::Range {
field,
lower_bound: Bound::Unbounded,
upper_bound: Bound::Excluded(OwnedValue::from(TantivyValue::try_from_anyelement(e)?)),
is_datetime,
},
};
Ok(query)
}
#[pg_extern(immutable, parallel_safe)]
pub fn regex(field: FieldName, pattern: String) -> SearchQueryInput {
SearchQueryInput::Regex { field, pattern }
}
#[pg_extern(immutable, parallel_safe)]
pub fn regex_phrase(
field: FieldName,
regexes: Vec,
slop: default!(Option, "NULL"),
max_expansions: default!(Option, "NULL"),
) -> SearchQueryInput {
SearchQueryInput::RegexPhrase {
field,
regexes,
slop: slop.map(|n| n as u32),
max_expansions: max_expansions.map(|n| n as u32),
}
}
macro_rules! term_fn {
($func_name:ident, $value_type:ty) => {
#[pg_extern(name = "term", immutable, parallel_safe)]
pub fn $func_name(
field: default!(Option, "NULL"),
value: default!(Option<$value_type>, "NULL"),
) -> SearchQueryInput {
if let Some(value) = value {
let tantivy_value = TantivyValue::try_from(value)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value();
let is_datetime = match tantivy_value {
OwnedValue::Date(_) => true,
_ => false,
};
SearchQueryInput::Term {
field,
value: tantivy_value,
is_datetime,
}
} else {
panic!("no value provided to term query")
}
}
};
}
#[pg_extern(name = "term", immutable, parallel_safe)]
pub fn term_anyenum(field: FieldName, value: AnyEnum) -> SearchQueryInput {
let tantivy_value = TantivyValue::try_from(value)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value();
let is_datetime = matches!(tantivy_value, OwnedValue::Date(_));
SearchQueryInput::Term {
field: Some(field),
value: tantivy_value,
is_datetime,
}
}
macro_rules! term_fn_unsupported {
($func_name:ident, $value_type:ty, $term_type:literal) => {
#[pg_extern(name = "term", immutable, parallel_safe)]
#[allow(unused)]
pub fn $func_name(
field: FieldName,
value: default!(Option<$value_type>, "NULL"),
) -> SearchQueryInput {
unimplemented!("{} in term query not implemented", $term_type)
}
};
}
// Generate functions for each type
// NOTE: We cannot use AnyElement for `term` because it sullies the user experience.
// For example, searching for a string value is an ambiguous type, so the user
// would have to search for 'string'::text or 'string'::varchar in the `value`
// argument.
term_fn!(term_bytes, Vec);
term_fn!(term_str, String);
term_fn!(term_i8, i8);
term_fn!(term_i16, i16);
term_fn!(term_i32, i32);
term_fn!(term_i64, i64);
term_fn!(term_f32, f32);
term_fn!(term_f64, f64);
term_fn!(term_bool, bool);
term_fn!(date, pgrx::datum::Date);
term_fn!(time, pgrx::datum::Time);
term_fn!(timestamp, pgrx::datum::Timestamp);
term_fn!(time_with_time_zone, pgrx::datum::TimeWithTimeZone);
term_fn!(timestamp_with_time_zone, pgrx::datum::TimestampWithTimeZone);
term_fn!(numeric, pgrx::AnyNumeric);
term_fn!(uuid, pgrx::Uuid);
term_fn_unsupported!(json, pgrx::Json, "json");
term_fn_unsupported!(jsonb, pgrx::JsonB, "jsonb");
term_fn_unsupported!(anyarray, pgrx::AnyArray, "array");
term_fn_unsupported!(pg_box, pgrx::pg_sys::BOX, "box");
term_fn_unsupported!(point, pgrx::pg_sys::Point, "point");
term_fn_unsupported!(tid, pgrx::pg_sys::ItemPointerData, "tid");
term_fn_unsupported!(inet, pgrx::Inet, "inet");
term_fn_unsupported!(int4range, pgrx::Range, "int4 range");
term_fn_unsupported!(int8range, pgrx::Range, "int8 range");
term_fn_unsupported!(numrange, pgrx::Range, "numeric range");
term_fn_unsupported!(daterange, pgrx::Range, "date range");
term_fn_unsupported!(
tsrange,
pgrx::Range,
"timestamp range"
);
term_fn_unsupported!(
tstzrange,
pgrx::Range,
"timestamp ranges with time zone"
);
macro_rules! range_term_fn {
($func_name:ident, $value_type:ty, $is_datetime:expr) => {
#[pg_extern(name = "range_term", immutable, parallel_safe)]
pub fn $func_name(field: FieldName, term: $value_type) -> SearchQueryInput {
SearchQueryInput::RangeTerm {
field,
value: TantivyValue::try_from(term)
.expect("term should be a valid TantivyValue representation")
.tantivy_schema_value(),
is_datetime: $is_datetime,
}
}
};
}
range_term_fn!(range_term_i8, i8, false);
range_term_fn!(range_term_i16, i16, false);
range_term_fn!(range_term_i32, i32, false);
range_term_fn!(range_term_i64, i64, false);
range_term_fn!(range_term_f32, f32, false);
range_term_fn!(range_term_f64, f64, false);
range_term_fn!(range_term_numeric, pgrx::AnyNumeric, false);
range_term_fn!(range_term_date, pgrx::datum::Date, true);
range_term_fn!(range_term_timestamp, pgrx::datum::Timestamp, true);
range_term_fn!(
range_term_timestamp_with_time_zone,
pgrx::datum::TimestampWithTimeZone,
true
);
#[derive(PostgresEnum, Serialize)]
pub enum RangeRelation {
Intersects,
Contains,
Within,
}
impl Display for RangeRelation {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
match self {
RangeRelation::Intersects => write!(f, "Intersects"),
RangeRelation::Contains => write!(f, "Contains"),
RangeRelation::Within => write!(f, "Within"),
}
}
}
macro_rules! range_term_range_fn {
($func_name:ident, $value_type:ty, $is_datetime:expr, $default:expr) => {
#[pg_extern(name = "range_term", immutable, parallel_safe)]
pub fn $func_name(
field: FieldName,
range: $value_type,
relation: RangeRelation,
) -> SearchQueryInput {
let (lower_bound, upper_bound) = match range.into_inner() {
None => (Bound::Included($default), Bound::Excluded($default)),
Some((lower, upper)) => {
let lower_bound = match lower {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(
TantivyValue::try_from(n)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value(),
),
RangeBound::Exclusive(n) => Bound::Excluded(
TantivyValue::try_from(n)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value(),
),
};
let upper_bound = match upper {
RangeBound::Infinite => Bound::Unbounded,
RangeBound::Inclusive(n) => Bound::Included(
TantivyValue::try_from(n)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value(),
),
RangeBound::Exclusive(n) => Bound::Excluded(
TantivyValue::try_from(n)
.expect("value should be a valid TantivyValue representation")
.tantivy_schema_value(),
),
};
(lower_bound, upper_bound)
}
};
match relation {
RangeRelation::Intersects => SearchQueryInput::RangeIntersects {
field,
lower_bound,
upper_bound,
is_datetime: $is_datetime,
},
RangeRelation::Contains => SearchQueryInput::RangeContains {
field,
lower_bound,
upper_bound,
is_datetime: $is_datetime,
},
RangeRelation::Within => SearchQueryInput::RangeWithin {
field,
lower_bound,
upper_bound,
is_datetime: $is_datetime,
},
}
}
};
}
range_term_range_fn!(
range_term_range_int4range,
pgrx::Range,
false,
OwnedValue::I64(0)
);
range_term_range_fn!(
range_term_range_int8range,
pgrx::Range,
false,
OwnedValue::I64(0)
);
range_term_range_fn!(
range_term_range_numrange,
pgrx::Range,
false,
OwnedValue::F64(0.0)
);
range_term_range_fn!(
range_term_range_daterange,
pgrx::Range,
true,
OwnedValue::Date(tantivy::DateTime::from_timestamp_micros(0))
);
range_term_range_fn!(
range_term_range_tsrange,
pgrx::Range,
true,
OwnedValue::Date(tantivy::DateTime::from_timestamp_micros(0))
);
range_term_range_fn!(
range_term_range_tstzrange,
pgrx::Range,
true,
OwnedValue::Date(tantivy::DateTime::from_timestamp_micros(0))
);
#[pg_extern(immutable, parallel_safe)]
pub fn term_set(
terms: default!(Vec, "ARRAY[]::searchqueryinput[]"),
) -> SearchQueryInput {
let terms: Vec<_> = terms
.into_iter()
.filter_map(|input| match input {
SearchQueryInput::Term {
field,
value,
is_datetime,
..
} => field.map(|field| TermInput {
field,
value,
is_datetime,
}),
_ => panic!("only term queries can be passed to term_set"),
})
.collect();
SearchQueryInput::TermSet { terms }
}
#[pg_cast(implicit)]
fn jsonb_to_searchqueryinput(query: JsonB) -> SearchQueryInput {
serde_path_to_error::deserialize(query.0).unwrap_or_else(|err| {
panic!(
r#"error parsing search query input json at "{}": {}"#,
err.path(),
match err.inner().to_string() {
msg if msg.contains("expected unit") => {
format!(
r#"invalid type: map, pass null as value for "{}""#,
err.path()
)
}
msg => msg,
}
)
})
}
extension_sql!(
"ALTER FUNCTION jsonb_to_searchqueryinput IMMUTABLE STRICT PARALLEL SAFE;",
name = "jsonb_to_searchqueryinput",
requires = [jsonb_to_searchqueryinput]
);