1use crate::{Expertise, Result, Scope};
4use sqlx::SqlitePool;
5use tracing::debug;
6
7#[derive(Debug, Clone, Default)]
9pub struct SearchOptions {
10 pub limit: Option<usize>,
12 pub offset: Option<usize>,
14 pub scope: Option<Scope>,
16 pub tags: Vec<String>,
18}
19
20impl SearchOptions {
21 pub fn new() -> Self {
23 Self::default()
24 }
25
26 pub fn limit(mut self, limit: usize) -> Self {
28 self.limit = Some(limit);
29 self
30 }
31
32 pub fn offset(mut self, offset: usize) -> Self {
34 self.offset = Some(offset);
35 self
36 }
37
38 pub fn scope(mut self, scope: Scope) -> Self {
40 self.scope = Some(scope);
41 self
42 }
43
44 pub fn tag(mut self, tag: impl Into<String>) -> Self {
46 self.tags.push(tag.into());
47 self
48 }
49
50 pub fn tags(mut self, tags: Vec<String>) -> Self {
52 self.tags = tags;
53 self
54 }
55}
56
57#[derive(Clone)]
59pub struct QueryBuilder {
60 pool: SqlitePool,
61}
62
63impl QueryBuilder {
64 pub(crate) fn new(pool: SqlitePool) -> Self {
66 Self { pool }
67 }
68
69 pub async fn search(&self, query: &str, options: SearchOptions) -> Result<Vec<Expertise>> {
92 debug!("Searching for: {}", query);
93
94 let mut sql = String::from(
95 r#"
96 SELECT e.data_json
97 FROM expertises e
98 WHERE e.id IN (SELECT id FROM expertises_fts WHERE expertises_fts MATCH ?)
99 "#,
100 );
101
102 let mut params: Vec<Box<dyn sqlx::Encode<'_, sqlx::Sqlite> + Send>> = vec![];
103 params.push(Box::new(query.to_string()));
104
105 if let Some(scope) = options.scope {
107 sql.push_str(" AND e.scope = ?");
108 params.push(Box::new(scope.as_str().to_string()));
109 }
110
111 if !options.tags.is_empty() {
113 for tag in &options.tags {
114 sql.push_str(" AND e.id IN (SELECT expertise_id FROM tags WHERE tag = ?)");
115 params.push(Box::new(tag.clone()));
116 }
117 }
118
119 sql.push_str(" ORDER BY e.updated_at DESC");
120
121 if options.limit.is_some() {
123 sql.push_str(" LIMIT ?");
124 }
125 if options.offset.is_some() {
126 sql.push_str(" OFFSET ?");
127 }
128
129 let mut query_builder = sqlx::query_as::<_, (String,)>(&sql);
131
132 query_builder = query_builder.bind(query);
134 if let Some(scope) = &options.scope {
135 query_builder = query_builder.bind(scope.as_str());
136 }
137 for tag in &options.tags {
138 query_builder = query_builder.bind(tag);
139 }
140 if let Some(limit) = options.limit {
141 query_builder = query_builder.bind(limit as i64);
142 }
143 if let Some(offset) = options.offset {
144 query_builder = query_builder.bind(offset as i64);
145 }
146
147 let rows = query_builder.fetch_all(&self.pool).await?;
148
149 let mut expertises = Vec::with_capacity(rows.len());
150 for (data_json,) in rows {
151 expertises.push(Expertise::from_json(&data_json)?);
152 }
153
154 debug!("Found {} results", expertises.len());
155 Ok(expertises)
156 }
157
158 pub async fn filter_by_tags(
160 &self,
161 tags: Vec<String>,
162 options: SearchOptions,
163 ) -> Result<Vec<Expertise>> {
164 debug!("Filtering by tags: {:?}", tags);
165
166 if tags.is_empty() {
167 return Ok(vec![]);
168 }
169
170 let mut sql = String::from(
171 r#"
172 SELECT DISTINCT e.data_json
173 FROM expertises e
174 INNER JOIN tags t ON e.id = t.expertise_id
175 WHERE t.tag IN (
176 "#,
177 );
178
179 for (i, _) in tags.iter().enumerate() {
181 if i > 0 {
182 sql.push_str(", ");
183 }
184 sql.push('?');
185 }
186 sql.push(')');
187
188 if options.scope.is_some() {
190 sql.push_str(" AND e.scope = ?");
191 }
192
193 sql.push_str(&format!(
195 " GROUP BY e.id HAVING COUNT(DISTINCT t.tag) = {}",
196 tags.len()
197 ));
198 sql.push_str(" ORDER BY e.updated_at DESC");
199
200 if let Some(limit) = options.limit {
202 sql.push_str(&format!(" LIMIT {}", limit));
203 }
204 if let Some(offset) = options.offset {
205 sql.push_str(&format!(" OFFSET {}", offset));
206 }
207
208 let mut query_builder = sqlx::query_as::<_, (String,)>(&sql);
209
210 for tag in &tags {
212 query_builder = query_builder.bind(tag);
213 }
214
215 if let Some(scope) = &options.scope {
217 query_builder = query_builder.bind(scope.as_str());
218 }
219
220 let rows = query_builder.fetch_all(&self.pool).await?;
221
222 let mut expertises = Vec::with_capacity(rows.len());
223 for (data_json,) in rows {
224 expertises.push(Expertise::from_json(&data_json)?);
225 }
226
227 debug!("Found {} results with tags {:?}", expertises.len(), tags);
228 Ok(expertises)
229 }
230
231 pub async fn list_tags(&self, scope: Option<Scope>) -> Result<Vec<(String, usize)>> {
233 debug!("Listing tags");
234
235 let mut sql = String::from(
236 r#"
237 SELECT t.tag, COUNT(*) as count
238 FROM tags t
239 "#,
240 );
241
242 if scope.is_some() {
243 sql.push_str(
244 r#"
245 INNER JOIN expertises e ON t.expertise_id = e.id
246 WHERE e.scope = ?
247 "#,
248 );
249 }
250
251 sql.push_str(" GROUP BY t.tag ORDER BY count DESC, t.tag");
252
253 let mut query_builder = sqlx::query_as::<_, (String, i64)>(&sql);
254
255 if let Some(scope) = scope {
256 query_builder = query_builder.bind(scope.as_str());
257 }
258
259 let rows = query_builder.fetch_all(&self.pool).await?;
260
261 Ok(rows
262 .into_iter()
263 .map(|(tag, count)| (tag, count as usize))
264 .collect())
265 }
266
267 pub async fn count(&self, scope: Option<Scope>) -> Result<usize> {
269 let sql = if scope.is_some() {
270 "SELECT COUNT(*) FROM expertises WHERE scope = ?"
271 } else {
272 "SELECT COUNT(*) FROM expertises"
273 };
274
275 let mut query_builder = sqlx::query_as::<_, (i64,)>(sql);
276
277 if let Some(scope) = scope {
278 query_builder = query_builder.bind(scope.as_str());
279 }
280
281 let (count,) = query_builder.fetch_one(&self.pool).await?;
282 Ok(count as usize)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::{Database, StorageOperations};
290 use tempfile::TempDir;
291
292 async fn setup_db() -> (Database, TempDir) {
293 let temp_dir = TempDir::new().unwrap();
294 let db_path = temp_dir.path().join("test.db");
295 let db = Database::open(&db_path).await.unwrap();
296 (db, temp_dir)
297 }
298
299 #[tokio::test]
300 async fn test_search() {
301 let (db, _temp) = setup_db().await;
302
303 let mut exp = Expertise::new("rust-expert", "1.0.0");
305 exp.inner.description = Some("Expert in Rust error handling".to_string());
306 exp.metadata.scope = Scope::Personal;
307
308 db.storage().create(exp).await.unwrap();
309
310 let options = SearchOptions::new();
312 let results = db.query().search("rust", options).await.unwrap();
313
314 assert_eq!(results.len(), 1);
315 assert_eq!(results[0].id(), "rust-expert");
316 }
317
318 #[tokio::test]
319 async fn test_filter_by_tags() {
320 let (db, _temp) = setup_db().await;
321
322 let mut exp1 = Expertise::new("exp-1", "1.0.0");
324 exp1.inner.tags = vec!["rust".to_string(), "async".to_string()];
325 exp1.metadata.scope = Scope::Personal;
326
327 let mut exp2 = Expertise::new("exp-2", "1.0.0");
328 exp2.inner.tags = vec!["rust".to_string()];
329 exp2.metadata.scope = Scope::Personal;
330
331 db.storage().create(exp1).await.unwrap();
332 db.storage().create(exp2).await.unwrap();
333
334 let options = SearchOptions::new();
336 let results = db
337 .query()
338 .filter_by_tags(vec!["rust".to_string()], options)
339 .await
340 .unwrap();
341 assert_eq!(results.len(), 2);
342
343 let options = SearchOptions::new();
345 let results = db
346 .query()
347 .filter_by_tags(vec!["rust".to_string(), "async".to_string()], options)
348 .await
349 .unwrap();
350 assert_eq!(results.len(), 1);
351 assert_eq!(results[0].id(), "exp-1");
352 }
353
354 #[tokio::test]
355 async fn test_list_tags() {
356 let (db, _temp) = setup_db().await;
357
358 let mut exp1 = Expertise::new("exp-1", "1.0.0");
359 exp1.inner.tags = vec!["rust".to_string(), "async".to_string()];
360 exp1.metadata.scope = Scope::Personal;
361
362 let mut exp2 = Expertise::new("exp-2", "1.0.0");
363 exp2.inner.tags = vec!["rust".to_string()];
364 exp2.metadata.scope = Scope::Personal;
365
366 db.storage().create(exp1).await.unwrap();
367 db.storage().create(exp2).await.unwrap();
368
369 let tags = db.query().list_tags(None).await.unwrap();
370
371 assert_eq!(tags.len(), 2);
372 assert_eq!(tags[0].0, "rust");
373 assert_eq!(tags[0].1, 2);
374 assert_eq!(tags[1].0, "async");
375 assert_eq!(tags[1].1, 1);
376 }
377
378 #[tokio::test]
379 async fn test_count() {
380 let (db, _temp) = setup_db().await;
381
382 let mut exp1 = Expertise::new("exp-1", "1.0.0");
383 exp1.metadata.scope = Scope::Personal;
384
385 let mut exp2 = Expertise::new("exp-2", "1.0.0");
386 exp2.metadata.scope = Scope::Company;
387
388 db.storage().create(exp1).await.unwrap();
389 db.storage().create(exp2).await.unwrap();
390
391 let total = db.query().count(None).await.unwrap();
392 assert_eq!(total, 2);
393
394 let personal = db.query().count(Some(Scope::Personal)).await.unwrap();
395 assert_eq!(personal, 1);
396 }
397}