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