Skip to main content

nodedb_types/
text_search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Text search parameter types shared across the NodeDb trait boundary.
4//!
5//! These are the user-facing knobs for full-text search queries. The
6//! implementation (BM25 scoring, BMW pruning, fuzzy matching) lives in
7//! `nodedb-fts`. These types are in `nodedb-types` so both `nodedb-client`
8//! (trait definition) and all implementations can use them without pulling
9//! in the full FTS engine as a dependency.
10
11use serde::{Deserialize, Serialize};
12
13/// Boolean query mode for full-text search.
14#[derive(
15    Debug,
16    Clone,
17    Copy,
18    PartialEq,
19    Eq,
20    Default,
21    Serialize,
22    Deserialize,
23    zerompk::ToMessagePack,
24    zerompk::FromMessagePack,
25)]
26pub enum QueryMode {
27    /// Any query term can match (union). Most permissive — best recall.
28    #[default]
29    Or,
30    /// All query terms must match (intersection). More precise — best precision.
31    And,
32}
33
34impl QueryMode {
35    /// The SQL spelling of the mode: `'or'` or `'and'`, as
36    /// `text_match(col, 'q', mode => 'or')` takes it.
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Or => "or",
40            Self::And => "and",
41        }
42    }
43
44    /// Parse the SQL spelling, ignoring ASCII case. `None` names no mode.
45    pub fn parse(text: &str) -> Option<Self> {
46        if text.eq_ignore_ascii_case("or") {
47            Some(Self::Or)
48        } else if text.eq_ignore_ascii_case("and") {
49            Some(Self::And)
50        } else {
51            None
52        }
53    }
54}
55
56/// BM25 ranking parameters.
57///
58/// Controls how term frequency and document length affect scoring.
59/// The defaults (`k1 = 1.2`, `b = 0.75`) are standard Okapi BM25 values
60/// that work well across most corpora.
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62pub struct Bm25Params {
63    /// Term frequency saturation factor.
64    /// Higher values give more weight to repeated terms. Range: 0.5–3.0.
65    /// Default: 1.2.
66    pub k1: f32,
67    /// Length normalization factor.
68    /// `0.0` = no length normalization, `1.0` = full normalization.
69    /// Default: 0.75.
70    pub b: f32,
71}
72
73impl Default for Bm25Params {
74    fn default() -> Self {
75        Self { k1: 1.2, b: 0.75 }
76    }
77}
78
79/// Per-query parameters for full-text search.
80///
81/// These are the knobs that vary per-query. BM25 scoring parameters (`k1`, `b`)
82/// are corpus-level settings configured at collection creation time — they depend
83/// on document characteristics (length, vocabulary), not on individual queries.
84///
85/// Pass [`TextSearchParams::default()`] for standard OR-mode non-fuzzy search.
86/// SQL `text_match` / `bm25_score` with no options run the same defaults.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub struct TextSearchParams {
89    /// Boolean query mode: `Or` (any term) or `And` (all terms).
90    /// Default: `Or`.
91    pub mode: QueryMode,
92    /// Enable fuzzy (Levenshtein distance) matching for approximate lookup.
93    /// Fuzzy hits are scored with a discount relative to exact matches.
94    /// Default: `false`.
95    pub fuzzy: bool,
96}
97
98impl Default for TextSearchParams {
99    fn default() -> Self {
100        Self {
101            mode: QueryMode::Or,
102            fuzzy: false,
103        }
104    }
105}
106
107/// Why a column cannot serve a full-text search.
108///
109/// Carried by the planner error, the Data-Plane error code, and the server
110/// error, so each surface renders the same SQLSTATE through [`Self::sqlstate`].
111#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
112pub enum TextColumnFault {
113    /// The strict schema declares no column of that name.
114    #[error("is not a column of the collection")]
115    Undeclared,
116    /// The column is declared with a non-text type.
117    #[error("has type {data_type}, not text")]
118    NotText { data_type: String },
119    /// The argument is an expression or literal, not a column reference.
120    #[error("is not a column reference; name a text column or use *")]
121    NotAColumn,
122    /// No document of the collection holds text under that field, while
123    /// other fields hold text.
124    #[error("holds no indexed text in any document")]
125    NotIndexed,
126}
127
128impl TextColumnFault {
129    /// SQLSTATE of the fault: `42703` (undefined_column) for a column that
130    /// does not exist as text, `42804` (datatype_mismatch) for an argument
131    /// that is not a text column.
132    pub fn sqlstate(&self) -> &'static str {
133        match self {
134            Self::Undeclared | Self::NotIndexed => crate::error::sqlstate::UNDEFINED_COLUMN,
135            Self::NotText { .. } | Self::NotAColumn => crate::error::sqlstate::DATATYPE_MISMATCH,
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn query_mode_sql_spelling_round_trips() {
146        for mode in [QueryMode::Or, QueryMode::And] {
147            assert_eq!(QueryMode::parse(mode.as_str()), Some(mode));
148        }
149        assert_eq!(QueryMode::parse("AND"), Some(QueryMode::And));
150        assert_eq!(QueryMode::parse("xor"), None);
151    }
152
153    #[test]
154    fn query_mode_round_trips_through_msgpack() {
155        for mode in [QueryMode::Or, QueryMode::And] {
156            let bytes = zerompk::to_msgpack_vec(&mode).expect("encode");
157            let back: QueryMode = zerompk::from_msgpack(&bytes).expect("decode");
158            assert_eq!(back, mode);
159        }
160    }
161
162    #[test]
163    fn faults_map_to_their_sqlstate() {
164        assert_eq!(TextColumnFault::Undeclared.sqlstate(), "42703");
165        assert_eq!(TextColumnFault::NotIndexed.sqlstate(), "42703");
166        assert_eq!(TextColumnFault::NotAColumn.sqlstate(), "42804");
167        let not_text = TextColumnFault::NotText {
168            data_type: "INT".into(),
169        };
170        assert_eq!(not_text.sqlstate(), "42804");
171        assert_eq!(not_text.to_string(), "has type INT, not text");
172    }
173}