1use 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 tx.execute("UPDATE search_configs SET is_active = 0, updated_at = ?", [&now_str])?;
253
254 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 assert!(repo.get_active().expect("get_active").is_none());
353
354 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 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 let c1_refreshed = repo.get(c1.id).expect("get c1").expect("found");
366 assert!(!c1_refreshed.is_active);
367 }
368}