Skip to main content

niwa_core/
query.rs

1//! Query and search operations
2
3use crate::{Expertise, Result, Scope};
4use sqlx::SqlitePool;
5use tracing::debug;
6
7/// Search options
8#[derive(Debug, Clone, Default)]
9pub struct SearchOptions {
10    /// Limit results
11    pub limit: Option<usize>,
12    /// Offset for pagination
13    pub offset: Option<usize>,
14    /// Filter by scope
15    pub scope: Option<Scope>,
16    /// Filter by tags (AND condition)
17    pub tags: Vec<String>,
18}
19
20impl SearchOptions {
21    /// Create a new SearchOptions
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Set limit
27    pub fn limit(mut self, limit: usize) -> Self {
28        self.limit = Some(limit);
29        self
30    }
31
32    /// Set offset
33    pub fn offset(mut self, offset: usize) -> Self {
34        self.offset = Some(offset);
35        self
36    }
37
38    /// Set scope filter
39    pub fn scope(mut self, scope: Scope) -> Self {
40        self.scope = Some(scope);
41        self
42    }
43
44    /// Add tag filter
45    pub fn tag(mut self, tag: impl Into<String>) -> Self {
46        self.tags.push(tag.into());
47        self
48    }
49
50    /// Set tags filter
51    pub fn tags(mut self, tags: Vec<String>) -> Self {
52        self.tags = tags;
53        self
54    }
55}
56
57/// Query builder for searching expertises
58#[derive(Clone)]
59pub struct QueryBuilder {
60    pool: SqlitePool,
61}
62
63impl QueryBuilder {
64    /// Create a new QueryBuilder
65    pub(crate) fn new(pool: SqlitePool) -> Self {
66        Self { pool }
67    }
68
69    /// Full-text search using FTS5
70    ///
71    /// # Arguments
72    ///
73    /// * `query` - Search query string
74    /// * `options` - Search options (limit, offset, filters)
75    ///
76    /// # Example
77    ///
78    /// ```no_run
79    /// use niwa_core::{Database, SearchOptions};
80    ///
81    /// #[tokio::main]
82    /// async fn main() -> anyhow::Result<()> {
83    ///     let db = Database::open_default().await?;
84    ///
85    ///     let options = SearchOptions::new().limit(10);
86    ///     let results = db.query().search("rust error handling", options).await?;
87    ///
88    ///     Ok(())
89    /// }
90    /// ```
91    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        // Add scope filter
106        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        // Add tag filters
112        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        // Add limit and offset
122        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        // Execute query (note: this is simplified, real implementation would use proper binding)
130        let mut query_builder = sqlx::query_as::<_, (String,)>(&sql);
131
132        // Bind parameters
133        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    /// Filter expertises by tags
159    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        // Add placeholders for tags
180        for (i, _) in tags.iter().enumerate() {
181            if i > 0 {
182                sql.push_str(", ");
183            }
184            sql.push('?');
185        }
186        sql.push(')');
187
188        // Add scope filter
189        if options.scope.is_some() {
190            sql.push_str(" AND e.scope = ?");
191        }
192
193        // Group by to ensure all tags match (AND condition)
194        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        // Add limit and offset
201        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        // Bind tags
211        for tag in &tags {
212            query_builder = query_builder.bind(tag);
213        }
214
215        // Bind scope
216        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    /// List all tags with counts
232    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    /// Count total expertises
268    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        // Create test expertise
304        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        // Search
311        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        // Create expertises with tags
323        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        // Filter by single tag
335        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        // Filter by multiple tags (AND condition)
344        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}