1use crate::analyzer::synonym::SynonymMap;
19use crate::backend::FtsBackend;
20use crate::index::writer::FtsIndex;
21use crate::scope::IndexScope;
22
23const SYNONYM_GROUPS_COLLECTION: IndexScope<'static> = IndexScope::document("_synonym_groups");
25
26const INDEX_SUBKEY: &str = "_index";
28
29#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
31pub struct SynonymGroupRecord {
32 pub name: String,
33 pub terms: Vec<String>,
34 pub created_at: u64,
35}
36
37impl<B: FtsBackend> FtsIndex<B> {
38 pub fn put_synonym_group(
40 &self,
41 database_id: u64,
42 tid: u64,
43 record: &SynonymGroupRecord,
44 ) -> Result<(), B::Error> {
45 let bytes = sonic_rs::to_vec(record).unwrap_or_default();
47 self.backend.write_meta(
48 database_id,
49 tid,
50 SYNONYM_GROUPS_COLLECTION,
51 &record.name,
52 &bytes,
53 )?;
54
55 let mut names = self.read_name_index(database_id, tid)?;
57 if !names.contains(&record.name) {
58 names.push(record.name.clone());
59 self.write_name_index(database_id, tid, &names)?;
60 }
61 Ok(())
62 }
63
64 pub fn delete_synonym_group(
66 &self,
67 database_id: u64,
68 tid: u64,
69 name: &str,
70 ) -> Result<bool, B::Error> {
71 let existed = self
74 .backend
75 .read_meta(database_id, tid, SYNONYM_GROUPS_COLLECTION, name)?
76 .is_some_and(|b| !b.is_empty());
77
78 if existed {
79 self.backend
81 .write_meta(database_id, tid, SYNONYM_GROUPS_COLLECTION, name, &[])?;
82 let mut names = self.read_name_index(database_id, tid)?;
84 names.retain(|n| n != name);
85 self.write_name_index(database_id, tid, &names)?;
86 }
87 Ok(existed)
88 }
89
90 pub fn get_synonym_group(
92 &self,
93 database_id: u64,
94 tid: u64,
95 name: &str,
96 ) -> Result<Option<SynonymGroupRecord>, B::Error> {
97 match self
98 .backend
99 .read_meta(database_id, tid, SYNONYM_GROUPS_COLLECTION, name)?
100 {
101 None => Ok(None),
102 Some(bytes) if bytes.is_empty() => Ok(None),
103 Some(bytes) => Ok(sonic_rs::from_slice::<SynonymGroupRecord>(&bytes).ok()),
104 }
105 }
106
107 pub fn list_synonym_groups(
109 &self,
110 database_id: u64,
111 tid: u64,
112 ) -> Result<Vec<SynonymGroupRecord>, B::Error> {
113 let names = self.read_name_index(database_id, tid)?;
114 let mut groups = Vec::with_capacity(names.len());
115 for name in &names {
116 if let Some(rec) = self.get_synonym_group(database_id, tid, name)? {
117 groups.push(rec);
118 }
119 }
120 Ok(groups)
121 }
122
123 pub fn build_synonym_map_for_tenant(
130 &self,
131 _database_id: u64,
132 _tid: u64,
133 all_groups: &[SynonymGroupRecord],
134 ) -> SynonymMap {
135 let mut map = SynonymMap::new();
136 for group in all_groups {
137 if group.terms.len() < 2 {
138 continue;
139 }
140 let analyzed: Vec<Vec<String>> = group
143 .terms
144 .iter()
145 .map(|t| crate::analyzer::pipeline::analyze(t))
146 .collect();
147
148 for (i, my_tokens) in analyzed.iter().enumerate() {
149 let other_tokens: Vec<&str> = analyzed
150 .iter()
151 .enumerate()
152 .filter(|(j, _)| *j != i)
153 .flat_map(|(_, ts)| ts.iter().map(|s| s.as_str()))
154 .collect();
155 for my_token in my_tokens {
156 map.add(my_token, &other_tokens);
157 }
158 }
159 }
160 map
161 }
162
163 pub fn expand_query_with_synonyms(
168 &self,
169 database_id: u64,
170 tid: u64,
171 tokens: Vec<String>,
172 ) -> Result<Vec<String>, B::Error> {
173 let groups = self.list_synonym_groups(database_id, tid)?;
174 if groups.is_empty() {
175 return Ok(tokens);
176 }
177 let map = self.build_synonym_map_for_tenant(database_id, tid, &groups);
178 let expanded = map.expand(&tokens);
179 Ok(expanded)
180 }
181
182 pub fn expand_query_groups(
186 &self,
187 database_id: u64,
188 tid: u64,
189 tokens: Vec<String>,
190 ) -> Result<Vec<Vec<String>>, B::Error> {
191 let groups = self.list_synonym_groups(database_id, tid)?;
192 if groups.is_empty() {
193 return Ok(tokens.into_iter().map(|token| vec![token]).collect());
194 }
195 let map = self.build_synonym_map_for_tenant(database_id, tid, &groups);
196 Ok(tokens
197 .into_iter()
198 .map(|token| {
199 let mut group = map.expand(std::slice::from_ref(&token));
200 let mut seen = std::collections::HashSet::new();
201 group.retain(|term| seen.insert(term.clone()));
202 group
203 })
204 .collect())
205 }
206
207 fn read_name_index(&self, database_id: u64, tid: u64) -> Result<Vec<String>, B::Error> {
210 match self
211 .backend
212 .read_meta(database_id, tid, SYNONYM_GROUPS_COLLECTION, INDEX_SUBKEY)?
213 {
214 None => Ok(Vec::new()),
215 Some(bytes) if bytes.is_empty() => Ok(Vec::new()),
216 Some(bytes) => Ok(sonic_rs::from_slice::<Vec<String>>(&bytes).unwrap_or_default()),
217 }
218 }
219
220 fn write_name_index(
221 &self,
222 database_id: u64,
223 tid: u64,
224 names: &[String],
225 ) -> Result<(), B::Error> {
226 let bytes = sonic_rs::to_vec(names).unwrap_or_default();
227 self.backend.write_meta(
228 database_id,
229 tid,
230 SYNONYM_GROUPS_COLLECTION,
231 INDEX_SUBKEY,
232 &bytes,
233 )
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use crate::backend::memory::MemoryBackend;
240 use crate::index::writer::FtsIndex;
241 use crate::test_support::test_governor;
242
243 use super::SynonymGroupRecord;
244
245 const DB: u64 = 0;
246 const T: u64 = 1;
247
248 fn idx() -> FtsIndex<MemoryBackend> {
249 FtsIndex::new(MemoryBackend::new(), test_governor())
250 }
251
252 fn rec(name: &str, terms: &[&str]) -> SynonymGroupRecord {
253 SynonymGroupRecord {
254 name: name.to_string(),
255 terms: terms.iter().map(|s| s.to_string()).collect(),
256 created_at: 0,
257 }
258 }
259
260 #[test]
261 fn put_and_get() {
262 let i = idx();
263 i.put_synonym_group(DB, T, &rec("db_terms", &["database", "db", "datastore"]))
264 .unwrap();
265 let got = i.get_synonym_group(DB, T, "db_terms").unwrap().unwrap();
266 assert_eq!(got.name, "db_terms");
267 assert_eq!(got.terms.len(), 3);
268 }
269
270 #[test]
271 fn delete_removes() {
272 let i = idx();
273 i.put_synonym_group(DB, T, &rec("g1", &["a", "b"])).unwrap();
274 assert!(i.delete_synonym_group(DB, T, "g1").unwrap());
275 assert!(!i.delete_synonym_group(DB, T, "g1").unwrap());
276 assert!(i.get_synonym_group(DB, T, "g1").unwrap().is_none());
277 }
278
279 #[test]
280 fn list_reflects_puts_and_deletes() {
281 let i = idx();
282 i.put_synonym_group(DB, T, &rec("g1", &["a", "b"])).unwrap();
283 i.put_synonym_group(DB, T, &rec("g2", &["x", "y"])).unwrap();
284 let names: Vec<String> = i
285 .list_synonym_groups(DB, T)
286 .unwrap()
287 .into_iter()
288 .map(|r| r.name)
289 .collect();
290 assert_eq!(names.len(), 2);
291
292 i.delete_synonym_group(DB, T, "g1").unwrap();
293 let names2: Vec<String> = i
294 .list_synonym_groups(DB, T)
295 .unwrap()
296 .into_iter()
297 .map(|r| r.name)
298 .collect();
299 assert_eq!(names2, vec!["g2"]);
300 }
301
302 #[test]
303 fn synonym_map_bidirectional() {
304 let i = idx();
305 let recs = vec![rec("db_terms", &["db", "database", "datastore"])];
306 let map = i.build_synonym_map_for_tenant(DB, T, &recs);
307
308 let expanded = map.expand(&["db".to_string()]);
311 assert!(expanded.contains(&"databas".to_string()));
312 assert!(expanded.contains(&"datastor".to_string()));
313
314 let expanded2 = map.expand(&["databas".to_string()]);
315 assert!(expanded2.contains(&"db".to_string()));
316 assert!(expanded2.contains(&"datastor".to_string()));
317 }
318
319 #[test]
320 fn expand_query_with_synonyms_no_groups() {
321 let i = idx();
322 let tokens = vec!["hello".to_string(), "world".to_string()];
323 let expanded = i.expand_query_with_synonyms(DB, T, tokens.clone()).unwrap();
324 assert_eq!(expanded, tokens);
325 }
326
327 #[test]
328 fn expand_query_expands_matching_token() {
329 let i = idx();
330 i.put_synonym_group(DB, T, &rec("db_terms", &["db", "database", "datastore"]))
331 .unwrap();
332 let tokens = vec!["db".to_string(), "perform".to_string()];
336 let expanded = i.expand_query_with_synonyms(DB, T, tokens).unwrap();
337 assert!(expanded.contains(&"db".to_string()));
338 assert!(expanded.contains(&"databas".to_string()));
339 assert!(expanded.contains(&"datastor".to_string()));
340 assert!(expanded.contains(&"perform".to_string()));
341 }
342}