agentroot_core/db/
user_metadata.rs

1//! User metadata operations on documents
2
3use super::Database;
4use crate::db::metadata::{MetadataFilter, UserMetadata};
5use crate::error::Result;
6use rusqlite::params;
7
8impl Database {
9    /// Add or update user metadata for a document
10    ///
11    /// # Arguments
12    /// * `docid` - Document ID (short hash like "abc123" or full "#abc123")
13    /// * `metadata` - User metadata to add/update
14    ///
15    /// # Example
16    /// ```ignore
17    /// use agentroot_core::{Database, MetadataBuilder};
18    ///
19    /// let db = Database::open("index.sqlite")?;
20    /// let metadata = MetadataBuilder::new()
21    ///     .text("author", "John Doe")
22    ///     .tags("labels", vec!["rust", "tutorial"])
23    ///     .build();
24    ///
25    /// db.add_metadata("#abc123", &metadata)?;
26    /// ```
27    pub fn add_metadata(&self, docid: &str, metadata: &UserMetadata) -> Result<()> {
28        let docid = docid.trim_start_matches('#');
29
30        // Find document by docid
31        let doc_id = self.conn.query_row(
32            "SELECT d.id FROM documents d 
33             JOIN content c ON c.hash = d.hash 
34             WHERE substr(c.hash, 1, 6) = ?1 AND d.active = 1 
35             LIMIT 1",
36            params![docid],
37            |row| row.get::<_, i64>(0),
38        )?;
39
40        // Get existing metadata
41        let existing_json: Option<String> = self
42            .conn
43            .query_row(
44                "SELECT user_metadata FROM documents WHERE id = ?1",
45                params![doc_id],
46                |row| row.get(0),
47            )
48            .ok()
49            .flatten();
50
51        // Merge with existing metadata
52        let mut combined = if let Some(json) = existing_json {
53            UserMetadata::from_json(&json).unwrap_or_default()
54        } else {
55            UserMetadata::new()
56        };
57
58        combined.merge(metadata);
59
60        // Update database
61        let json = combined.to_json()?;
62        self.conn.execute(
63            "UPDATE documents SET user_metadata = ?1 WHERE id = ?2",
64            params![json, doc_id],
65        )?;
66
67        Ok(())
68    }
69
70    /// Get user metadata for a document
71    pub fn get_metadata(&self, docid: &str) -> Result<Option<UserMetadata>> {
72        let docid = docid.trim_start_matches('#');
73
74        let result: Option<String> = self
75            .conn
76            .query_row(
77                "SELECT d.user_metadata FROM documents d 
78             JOIN content c ON c.hash = d.hash 
79             WHERE substr(c.hash, 1, 6) = ?1 AND d.active = 1 
80             LIMIT 1",
81                params![docid],
82                |row| row.get(0),
83            )
84            .ok()
85            .flatten();
86
87        match result {
88            Some(json) => Ok(Some(UserMetadata::from_json(&json)?)),
89            None => Ok(None),
90        }
91    }
92
93    /// Remove specific metadata fields from a document
94    pub fn remove_metadata_fields(&self, docid: &str, fields: &[String]) -> Result<()> {
95        if let Some(mut metadata) = self.get_metadata(docid)? {
96            for field in fields {
97                metadata.remove(field);
98            }
99
100            let docid_clean = docid.trim_start_matches('#');
101            let doc_id: i64 = self.conn.query_row(
102                "SELECT d.id FROM documents d 
103                 JOIN content c ON c.hash = d.hash 
104                 WHERE substr(c.hash, 1, 6) = ?1 AND d.active = 1 
105                 LIMIT 1",
106                params![docid_clean],
107                |row| row.get(0),
108            )?;
109
110            let json = metadata.to_json()?;
111            self.conn.execute(
112                "UPDATE documents SET user_metadata = ?1 WHERE id = ?2",
113                params![json, doc_id],
114            )?;
115        }
116
117        Ok(())
118    }
119
120    /// Clear all user metadata from a document
121    pub fn clear_metadata(&self, docid: &str) -> Result<()> {
122        let docid = docid.trim_start_matches('#');
123
124        self.conn.execute(
125            "UPDATE documents d 
126             SET user_metadata = NULL 
127             WHERE d.id IN (
128                 SELECT d2.id FROM documents d2
129                 JOIN content c ON c.hash = d2.hash 
130                 WHERE substr(c.hash, 1, 6) = ?1 AND d2.active = 1 
131                 LIMIT 1
132             )",
133            params![docid],
134        )?;
135
136        Ok(())
137    }
138
139    /// Find documents matching metadata filter
140    pub fn find_by_metadata(&self, filter: &MetadataFilter, limit: usize) -> Result<Vec<String>> {
141        let mut stmt = self.conn.prepare(
142            "SELECT d.id, c.hash, d.user_metadata 
143             FROM documents d 
144             JOIN content c ON c.hash = d.hash 
145             WHERE d.active = 1 AND d.user_metadata IS NOT NULL 
146             LIMIT ?1",
147        )?;
148
149        let docids: Vec<String> = stmt
150            .query_map(params![limit], |row| {
151                let hash: String = row.get(1)?;
152                let metadata_json: Option<String> = row.get(2)?;
153
154                if let Some(json) = metadata_json {
155                    if let Ok(metadata) = UserMetadata::from_json(&json) {
156                        if filter.matches(&metadata) {
157                            return Ok(Some(format!("#{}", &hash[..6])));
158                        }
159                    }
160                }
161                Ok(None)
162            })?
163            .filter_map(|r| r.ok().flatten())
164            .collect();
165
166        Ok(docids)
167    }
168
169    /// List all documents with user metadata
170    pub fn list_with_metadata(&self, limit: usize) -> Result<Vec<(String, UserMetadata)>> {
171        let mut stmt = self.conn.prepare(
172            "SELECT c.hash, d.user_metadata 
173             FROM documents d 
174             JOIN content c ON c.hash = d.hash 
175             WHERE d.active = 1 AND d.user_metadata IS NOT NULL 
176             LIMIT ?1",
177        )?;
178
179        let results: Vec<(String, UserMetadata)> = stmt
180            .query_map(params![limit], |row| {
181                let hash: String = row.get(0)?;
182                let metadata_json: String = row.get(1)?;
183                Ok((hash, metadata_json))
184            })?
185            .filter_map(|r| r.ok())
186            .filter_map(|(hash, json)| {
187                UserMetadata::from_json(&json)
188                    .ok()
189                    .map(|m| (format!("#{}", &hash[..6]), m))
190            })
191            .collect();
192
193        Ok(results)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::db::metadata::{MetadataBuilder, MetadataFilter};
201    use chrono::Utc;
202
203    #[test]
204    fn test_add_and_get_metadata() {
205        let db = Database::open_in_memory().unwrap();
206        db.initialize().unwrap();
207
208        // Insert a test document
209        let now = Utc::now().to_rfc3339();
210        db.insert_content("testhash123", "test content").unwrap();
211        db.insert_document(
212            "test",
213            "test.md",
214            "Test",
215            "testhash123",
216            &now,
217            &now,
218            "file",
219            None,
220        )
221        .unwrap();
222
223        // Add metadata
224        let metadata = MetadataBuilder::new()
225            .text("author", "Alice")
226            .tags("labels", vec!["test", "example"])
227            .integer("version", 1)
228            .build();
229
230        // Get docid
231        let docid = format!("#{}", &"testhash123"[..6]);
232
233        db.add_metadata(&docid, &metadata).unwrap();
234
235        // Retrieve metadata
236        let retrieved = db.get_metadata(&docid).unwrap();
237        assert!(retrieved.is_some());
238
239        let retrieved = retrieved.unwrap();
240        assert!(retrieved.contains("author"));
241        assert!(retrieved.contains("labels"));
242        assert!(retrieved.contains("version"));
243    }
244
245    #[test]
246    fn test_metadata_merge() {
247        let db = Database::open_in_memory().unwrap();
248        db.initialize().unwrap();
249
250        let now = Utc::now().to_rfc3339();
251        db.insert_content("testhash456", "test content").unwrap();
252        db.insert_document(
253            "test",
254            "test.md",
255            "Test",
256            "testhash456",
257            &now,
258            &now,
259            "file",
260            None,
261        )
262        .unwrap();
263
264        let docid = format!("#{}", &"testhash456"[..6]);
265
266        // Add initial metadata
267        let meta1 = MetadataBuilder::new().text("author", "Alice").build();
268        db.add_metadata(&docid, &meta1).unwrap();
269
270        // Add more metadata (should merge)
271        let meta2 = MetadataBuilder::new().tags("labels", vec!["rust"]).build();
272        db.add_metadata(&docid, &meta2).unwrap();
273
274        // Should have both fields
275        let retrieved = db.get_metadata(&docid).unwrap().unwrap();
276        assert!(retrieved.contains("author"));
277        assert!(retrieved.contains("labels"));
278    }
279
280    #[test]
281    fn test_find_by_metadata() {
282        let db = Database::open_in_memory().unwrap();
283        db.initialize().unwrap();
284
285        let now = Utc::now().to_rfc3339();
286
287        // Insert multiple documents with distinct hashes
288        for i in 1..=3 {
289            let hash = format!("hash{}_abcdef", i);
290            let content = format!("content {}", i);
291            db.insert_content(&hash, &content).unwrap();
292            db.insert_document(
293                "test",
294                &format!("doc{}.md", i),
295                "Test",
296                &hash,
297                &now,
298                &now,
299                "file",
300                None,
301            )
302            .unwrap();
303
304            let metadata = MetadataBuilder::new().integer("score", i as i64).build();
305
306            let docid = format!("#{}", &hash[..6]);
307            db.add_metadata(&docid, &metadata).unwrap();
308        }
309
310        // Find documents with score > 1
311        let filter = MetadataFilter::IntegerGt("score".to_string(), 1);
312        let results = db.find_by_metadata(&filter, 10).unwrap();
313
314        assert_eq!(results.len(), 2); // hash2_abcdef and hash3_abcdef
315    }
316}