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}