Skip to main content

stateset_db/sqlite/
search_configs.rs

1//! SQLite implementation of search configuration repository
2
3use super::{
4    map_db_error, parse_datetime_row, parse_json_row, parse_uuid_row, with_immediate_transaction,
5};
6use chrono::Utc;
7use r2d2::Pool;
8use r2d2_sqlite::SqliteConnectionManager;
9use stateset_core::{
10    BoostRule, CommerceError, CreateSearchConfig, FacetConfig, Result, SearchConfig,
11    SearchConfigFilter, SearchConfigId, SearchConfigRepository, SearchField, SynonymGroup,
12    UpdateSearchConfig,
13};
14
15#[derive(Debug)]
16pub struct SqliteSearchConfigRepository {
17    pool: Pool<SqliteConnectionManager>,
18}
19
20impl SqliteSearchConfigRepository {
21    #[must_use]
22    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
23        Self { pool }
24    }
25
26    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
27        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
28    }
29
30    fn row_to_config(row: &rusqlite::Row<'_>) -> rusqlite::Result<SearchConfig> {
31        let fields_json: String = row.get("searchable_fields")?;
32        let facets_json: String = row.get("facets")?;
33        let synonyms_json: String = row.get("synonyms")?;
34        let boost_rules_json: String = row.get("boost_rules")?;
35
36        Ok(SearchConfig {
37            id: parse_uuid_row(&row.get::<_, String>("id")?, "search_config", "id")?.into(),
38            name: row.get("name")?,
39            description: row.get("description")?,
40            searchable_fields: parse_json_row::<Vec<SearchField>>(
41                &fields_json,
42                "search_config",
43                "searchable_fields",
44            )?,
45            facets: parse_json_row::<Vec<FacetConfig>>(&facets_json, "search_config", "facets")?,
46            synonyms: parse_json_row::<Vec<SynonymGroup>>(
47                &synonyms_json,
48                "search_config",
49                "synonyms",
50            )?,
51            boost_rules: parse_json_row::<Vec<BoostRule>>(
52                &boost_rules_json,
53                "search_config",
54                "boost_rules",
55            )?,
56            is_active: row.get::<_, i32>("is_active")? != 0,
57            created_at: parse_datetime_row(
58                &row.get::<_, String>("created_at")?,
59                "search_config",
60                "created_at",
61            )?,
62            updated_at: parse_datetime_row(
63                &row.get::<_, String>("updated_at")?,
64                "search_config",
65                "updated_at",
66            )?,
67        })
68    }
69}
70
71impl SearchConfigRepository for SqliteSearchConfigRepository {
72    fn create(&self, input: CreateSearchConfig) -> Result<SearchConfig> {
73        let id = SearchConfigId::new();
74        let now = Utc::now();
75        let id_str = id.to_string();
76        let now_str = now.to_rfc3339();
77
78        let fields_json = serde_json::to_string(&input.searchable_fields)
79            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
80        let facets_json = serde_json::to_string(&input.facets)
81            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
82        let synonyms_json = serde_json::to_string(&input.synonyms)
83            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
84        let boost_rules_json = serde_json::to_string(&input.boost_rules)
85            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
86
87        with_immediate_transaction(&self.pool, |tx| {
88            tx.execute(
89                "INSERT INTO search_configs (id, name, description, searchable_fields, facets, synonyms, boost_rules, is_active, created_at, updated_at)
90                 VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
91                rusqlite::params![
92                    &id_str,
93                    &input.name,
94                    &input.description,
95                    &fields_json,
96                    &facets_json,
97                    &synonyms_json,
98                    &boost_rules_json,
99                    &now_str,
100                    &now_str,
101                ],
102            )?;
103
104            tx.query_row(
105                "SELECT * FROM search_configs WHERE id = ?",
106                [&id_str],
107                Self::row_to_config,
108            )
109        })
110    }
111
112    fn get(&self, id: SearchConfigId) -> Result<Option<SearchConfig>> {
113        let conn = self.conn()?;
114        match conn.query_row(
115            "SELECT * FROM search_configs WHERE id = ?",
116            [id.to_string()],
117            Self::row_to_config,
118        ) {
119            Ok(c) => Ok(Some(c)),
120            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
121            Err(e) => Err(map_db_error(e)),
122        }
123    }
124
125    fn update(&self, id: SearchConfigId, input: UpdateSearchConfig) -> Result<SearchConfig> {
126        let id_str = id.to_string();
127        let now_str = Utc::now().to_rfc3339();
128
129        with_immediate_transaction(&self.pool, |tx| {
130            let mut sets = vec!["updated_at = ?".to_string()];
131            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now_str.clone())];
132
133            if let Some(ref name) = input.name {
134                sets.push("name = ?".into());
135                params.push(Box::new(name.clone()));
136            }
137            if let Some(ref description) = input.description {
138                sets.push("description = ?".into());
139                params.push(Box::new(description.clone()));
140            }
141            if let Some(ref fields) = input.searchable_fields {
142                let json = serde_json::to_string(fields).map_err(|e| {
143                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
144                        e.to_string(),
145                    )))
146                })?;
147                sets.push("searchable_fields = ?".into());
148                params.push(Box::new(json));
149            }
150            if let Some(ref facets) = input.facets {
151                let json = serde_json::to_string(facets).map_err(|e| {
152                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
153                        e.to_string(),
154                    )))
155                })?;
156                sets.push("facets = ?".into());
157                params.push(Box::new(json));
158            }
159            if let Some(ref synonyms) = input.synonyms {
160                let json = serde_json::to_string(synonyms).map_err(|e| {
161                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
162                        e.to_string(),
163                    )))
164                })?;
165                sets.push("synonyms = ?".into());
166                params.push(Box::new(json));
167            }
168            if let Some(ref boost_rules) = input.boost_rules {
169                let json = serde_json::to_string(boost_rules).map_err(|e| {
170                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
171                        e.to_string(),
172                    )))
173                })?;
174                sets.push("boost_rules = ?".into());
175                params.push(Box::new(json));
176            }
177            if let Some(is_active) = input.is_active {
178                sets.push("is_active = ?".into());
179                params.push(Box::new(is_active as i32));
180            }
181
182            let sql = format!("UPDATE search_configs SET {} WHERE id = ?", sets.join(", "));
183            params.push(Box::new(id_str.clone()));
184
185            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
186                params.iter().map(|p| p.as_ref()).collect();
187            tx.execute(&sql, param_refs.as_slice())?;
188
189            tx.query_row(
190                "SELECT * FROM search_configs WHERE id = ?",
191                [&id_str],
192                Self::row_to_config,
193            )
194        })
195    }
196
197    fn list(&self, filter: SearchConfigFilter) -> Result<Vec<SearchConfig>> {
198        let conn = self.conn()?;
199        let mut sql = "SELECT * FROM search_configs WHERE 1=1".to_string();
200        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
201
202        if let Some(is_active) = filter.is_active {
203            sql.push_str(" AND is_active = ?");
204            params.push(Box::new(is_active as i32));
205        }
206        if let Some(ref name) = filter.name {
207            sql.push_str(" AND name LIKE ?");
208            params.push(Box::new(format!("%{name}%")));
209        }
210
211        sql.push_str(" ORDER BY created_at DESC");
212
213        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
214
215        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
216            params.iter().map(|p| p.as_ref()).collect();
217        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
218        let rows = stmt
219            .query_map(param_refs.as_slice(), Self::row_to_config)
220            .map_err(map_db_error)?
221            .collect::<std::result::Result<Vec<_>, _>>()
222            .map_err(map_db_error)?;
223        Ok(rows)
224    }
225
226    fn delete(&self, id: SearchConfigId) -> Result<()> {
227        let conn = self.conn()?;
228        conn.execute("DELETE FROM search_configs WHERE id = ?", [id.to_string()])
229            .map_err(map_db_error)?;
230        Ok(())
231    }
232
233    fn get_active(&self) -> Result<Option<SearchConfig>> {
234        let conn = self.conn()?;
235        match conn.query_row(
236            "SELECT * FROM search_configs WHERE is_active = 1 LIMIT 1",
237            [],
238            Self::row_to_config,
239        ) {
240            Ok(c) => Ok(Some(c)),
241            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
242            Err(e) => Err(map_db_error(e)),
243        }
244    }
245
246    fn set_active(&self, id: SearchConfigId) -> Result<SearchConfig> {
247        let id_str = id.to_string();
248        let now_str = Utc::now().to_rfc3339();
249
250        with_immediate_transaction(&self.pool, |tx| {
251            // Deactivate all configs first
252            tx.execute("UPDATE search_configs SET is_active = 0, updated_at = ?", [&now_str])?;
253
254            // Activate the requested config
255            tx.execute(
256                "UPDATE search_configs SET is_active = 1, updated_at = ? WHERE id = ?",
257                rusqlite::params![&now_str, &id_str],
258            )?;
259
260            tx.query_row(
261                "SELECT * FROM search_configs WHERE id = ?",
262                [&id_str],
263                Self::row_to_config,
264            )
265        })
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::DatabaseConfig;
273    use crate::sqlite::SqliteDatabase;
274    use stateset_core::{SearchField, Tokenizer};
275
276    fn test_repo() -> SqliteSearchConfigRepository {
277        let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
278        let conn = db.conn().expect("conn");
279        conn.execute_batch(
280            "CREATE TABLE IF NOT EXISTS search_configs (
281                id TEXT PRIMARY KEY,
282                name TEXT NOT NULL,
283                description TEXT,
284                searchable_fields TEXT NOT NULL DEFAULT '[]',
285                facets TEXT NOT NULL DEFAULT '[]',
286                synonyms TEXT NOT NULL DEFAULT '[]',
287                boost_rules TEXT NOT NULL DEFAULT '[]',
288                is_active INTEGER NOT NULL DEFAULT 0,
289                created_at TEXT NOT NULL DEFAULT (datetime('now')),
290                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
291            );",
292        )
293        .expect("create table");
294        SqliteSearchConfigRepository::new(db.pool().clone())
295    }
296
297    #[test]
298    fn create_and_get_config() {
299        let repo = test_repo();
300        let config = repo
301            .create(CreateSearchConfig {
302                name: "Default Config".into(),
303                description: Some("Product search config".into()),
304                searchable_fields: vec![SearchField {
305                    field_name: "title".into(),
306                    weight: 2.0,
307                    tokenizer: Tokenizer::Standard,
308                    enabled: true,
309                }],
310                facets: vec![],
311                synonyms: vec![],
312                boost_rules: vec![],
313            })
314            .expect("create");
315
316        assert_eq!(config.name, "Default Config");
317        assert_eq!(config.searchable_fields.len(), 1);
318        assert!(!config.is_active);
319
320        let fetched = repo.get(config.id).expect("get").expect("found");
321        assert_eq!(fetched.id, config.id);
322        assert_eq!(fetched.searchable_fields[0].field_name, "title");
323    }
324
325    #[test]
326    fn set_active_and_get_active() {
327        let repo = test_repo();
328
329        let c1 = repo
330            .create(CreateSearchConfig {
331                name: "Config A".into(),
332                description: None,
333                searchable_fields: vec![],
334                facets: vec![],
335                synonyms: vec![],
336                boost_rules: vec![],
337            })
338            .expect("create A");
339
340        let c2 = repo
341            .create(CreateSearchConfig {
342                name: "Config B".into(),
343                description: None,
344                searchable_fields: vec![],
345                facets: vec![],
346                synonyms: vec![],
347                boost_rules: vec![],
348            })
349            .expect("create B");
350
351        // Initially no active config
352        assert!(repo.get_active().expect("get_active").is_none());
353
354        // Activate c1
355        repo.set_active(c1.id).expect("set_active c1");
356        let active = repo.get_active().expect("get_active").expect("found");
357        assert_eq!(active.id, c1.id);
358
359        // Activate c2 — c1 should be deactivated
360        repo.set_active(c2.id).expect("set_active c2");
361        let active = repo.get_active().expect("get_active").expect("found");
362        assert_eq!(active.id, c2.id);
363
364        // c1 should no longer be active
365        let c1_refreshed = repo.get(c1.id).expect("get c1").expect("found");
366        assert!(!c1_refreshed.is_active);
367    }
368}