metis_core/dal/database/
repository.rs

1use crate::dal::database::models::*;
2use crate::dal::database::schema;
3use crate::{MetisError, Result};
4use diesel::prelude::*;
5use diesel::sqlite::SqliteConnection;
6
7/// Data access repository for document operations
8pub struct DocumentRepository {
9    connection: SqliteConnection,
10}
11
12impl DocumentRepository {
13    pub fn new(connection: SqliteConnection) -> Self {
14        Self { connection }
15    }
16
17    /// Insert a new document into the database
18    pub fn create_document(&mut self, doc: NewDocument) -> Result<Document> {
19        use schema::documents::dsl::*;
20
21        diesel::insert_into(documents)
22            .values(&doc)
23            .returning(Document::as_returning())
24            .get_result(&mut self.connection)
25            .map_err(MetisError::Database)
26    }
27
28    /// Find a document by its filepath
29    pub fn find_by_filepath(&mut self, file_path: &str) -> Result<Option<Document>> {
30        use schema::documents::dsl::*;
31
32        documents
33            .filter(filepath.eq(file_path))
34            .first(&mut self.connection)
35            .optional()
36            .map_err(MetisError::Database)
37    }
38
39    /// Find a document by its ID
40    pub fn find_by_id(&mut self, document_id: &str) -> Result<Option<Document>> {
41        use schema::documents::dsl::*;
42
43        documents
44            .filter(id.eq(document_id))
45            .first(&mut self.connection)
46            .optional()
47            .map_err(MetisError::Database)
48    }
49
50    /// Update an existing document
51    pub fn update_document(&mut self, file_path: &str, doc: &Document) -> Result<Document> {
52        use schema::documents::dsl::*;
53
54        diesel::update(documents.filter(filepath.eq(file_path)))
55            .set(doc)
56            .returning(Document::as_returning())
57            .get_result(&mut self.connection)
58            .map_err(MetisError::Database)
59    }
60
61    /// Delete a document and all its relationships
62    pub fn delete_document(&mut self, file_path: &str) -> Result<bool> {
63        use schema::documents::dsl::*;
64
65        let deleted_count = diesel::delete(documents.filter(filepath.eq(file_path)))
66            .execute(&mut self.connection)
67            .map_err(MetisError::Database)?;
68
69        Ok(deleted_count > 0)
70    }
71
72    /// Find all children of a document
73    pub fn find_children(&mut self, parent_document_id: &str) -> Result<Vec<Document>> {
74        use schema::document_relationships::dsl::*;
75        use schema::documents::dsl::*;
76
77        documents
78            .inner_join(document_relationships.on(id.eq(child_id)))
79            .filter(parent_id.eq(parent_document_id))
80            .select(Document::as_select())
81            .load(&mut self.connection)
82            .map_err(MetisError::Database)
83    }
84
85    /// Find the parent of a document
86    pub fn find_parent(&mut self, child_document_id: &str) -> Result<Option<Document>> {
87        use schema::document_relationships::dsl::*;
88        use schema::documents::dsl::*;
89
90        documents
91            .inner_join(document_relationships.on(id.eq(parent_id)))
92            .filter(child_id.eq(child_document_id))
93            .select(Document::as_select())
94            .first(&mut self.connection)
95            .optional()
96            .map_err(MetisError::Database)
97    }
98
99    /// Create a parent-child relationship
100    pub fn create_relationship(&mut self, relationship: DocumentRelationship) -> Result<()> {
101        use schema::document_relationships::dsl::*;
102
103        diesel::insert_into(document_relationships)
104            .values(&relationship)
105            .execute(&mut self.connection)
106            .map_err(MetisError::Database)?;
107
108        Ok(())
109    }
110
111    /// Search documents using FTS
112    pub fn search_documents(&mut self, query: &str) -> Result<Vec<Document>> {
113        // For SQLite FTS, we need to use sql_query for the MATCH operator
114        diesel::sql_query(
115            "
116            SELECT d.* FROM documents d
117            INNER JOIN document_search ds ON d.filepath = ds.document_filepath
118            WHERE document_search MATCH ?
119        ",
120        )
121        .bind::<diesel::sql_types::Text, _>(query)
122        .load::<Document>(&mut self.connection)
123        .map_err(MetisError::Database)
124    }
125
126    /// Get all documents of a specific type
127    pub fn find_by_type(&mut self, doc_type: &str) -> Result<Vec<Document>> {
128        use schema::documents::dsl::*;
129
130        documents
131            .filter(document_type.eq(doc_type))
132            .order(updated_at.desc())
133            .load(&mut self.connection)
134            .map_err(MetisError::Database)
135    }
136
137    /// Get documents with specific tags
138    pub fn find_by_tag(&mut self, tag_name: &str) -> Result<Vec<Document>> {
139        use schema::document_tags::dsl::*;
140        use schema::documents::dsl::*;
141
142        documents
143            .inner_join(document_tags.on(filepath.eq(document_filepath)))
144            .filter(tag.eq(tag_name))
145            .select(Document::as_select())
146            .load(&mut self.connection)
147            .map_err(MetisError::Database)
148    }
149
150    /// Get documents in a specific phase
151    pub fn find_by_phase(&mut self, phase_name: &str) -> Result<Vec<Document>> {
152        use schema::documents::dsl::*;
153
154        documents
155            .filter(phase.eq(phase_name))
156            .order(updated_at.desc())
157            .load(&mut self.connection)
158            .map_err(MetisError::Database)
159    }
160
161    /// Get documents by type and phase
162    pub fn find_by_type_and_phase(
163        &mut self,
164        doc_type: &str,
165        phase_name: &str,
166    ) -> Result<Vec<Document>> {
167        use schema::documents::dsl::*;
168
169        documents
170            .filter(document_type.eq(doc_type))
171            .filter(phase.eq(phase_name))
172            .order(updated_at.desc())
173            .load(&mut self.connection)
174            .map_err(MetisError::Database)
175    }
176
177    /// Get all documents belonging to a strategy
178    pub fn find_by_strategy_id(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
179        use schema::documents::dsl::*;
180
181        documents
182            .filter(strategy_id.eq(strategy_document_id))
183            .order(updated_at.desc())
184            .load(&mut self.connection)
185            .map_err(MetisError::Database)
186    }
187
188    /// Get all documents belonging to an initiative
189    pub fn find_by_initiative_id(&mut self, initiative_document_id: &str) -> Result<Vec<Document>> {
190        use schema::documents::dsl::*;
191
192        documents
193            .filter(initiative_id.eq(initiative_document_id))
194            .order(updated_at.desc())
195            .load(&mut self.connection)
196            .map_err(MetisError::Database)
197    }
198
199    /// Get all documents in a strategy hierarchy (strategy + its initiatives + their tasks)
200    pub fn find_strategy_hierarchy(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
201        use schema::documents::dsl::*;
202
203        documents
204            .filter(
205                id.eq(strategy_document_id)
206                    .or(strategy_id.eq(strategy_document_id))
207            )
208            .order((document_type.asc(), updated_at.desc()))
209            .load(&mut self.connection)
210            .map_err(MetisError::Database)
211    }
212
213    /// Get all documents in an initiative hierarchy (initiative + its tasks)
214    pub fn find_initiative_hierarchy(&mut self, initiative_document_id: &str) -> Result<Vec<Document>> {
215        use schema::documents::dsl::*;
216
217        documents
218            .filter(
219                id.eq(initiative_document_id)
220                    .or(initiative_id.eq(initiative_document_id))
221            )
222            .order((document_type.asc(), updated_at.desc()))
223            .load(&mut self.connection)
224            .map_err(MetisError::Database)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::dal::Database;
232
233    fn setup_test_repository() -> DocumentRepository {
234        let db = Database::new(":memory:").expect("Failed to create test database");
235        db.into_repository()
236    }
237
238    fn create_test_document() -> NewDocument {
239        NewDocument {
240            filepath: "/test/doc.md".to_string(),
241            id: "test-doc-1".to_string(),
242            title: "Test Document".to_string(),
243            document_type: "vision".to_string(),
244            created_at: 1609459200.0, // 2021-01-01
245            updated_at: 1609459200.0,
246            archived: false,
247            exit_criteria_met: false,
248            file_hash: "abc123".to_string(),
249            frontmatter_json: "{}".to_string(),
250            content: Some("Test content".to_string()),
251            phase: "draft".to_string(),
252            strategy_id: None,
253            initiative_id: None,
254        }
255    }
256
257    #[test]
258    fn test_create_and_find_document() {
259        let mut repo = setup_test_repository();
260
261        let new_doc = create_test_document();
262        let created = repo
263            .create_document(new_doc)
264            .expect("Failed to create document");
265
266        assert_eq!(created.filepath, "/test/doc.md");
267        assert_eq!(created.title, "Test Document");
268        assert_eq!(created.document_type, "vision");
269
270        // Test find by filepath
271        let found = repo
272            .find_by_filepath("/test/doc.md")
273            .expect("Failed to find document")
274            .expect("Document not found");
275        assert_eq!(found.id, "test-doc-1");
276
277        // Test find by id
278        let found_by_id = repo
279            .find_by_id("test-doc-1")
280            .expect("Failed to find document")
281            .expect("Document not found");
282        assert_eq!(found_by_id.filepath, "/test/doc.md");
283    }
284
285    #[test]
286    fn test_update_document() {
287        let mut repo = setup_test_repository();
288
289        let new_doc = create_test_document();
290        let mut created = repo
291            .create_document(new_doc)
292            .expect("Failed to create document");
293
294        // Update the document
295        created.title = "Updated Title".to_string();
296        created.updated_at = 1609462800.0; // 1 hour later
297
298        let updated = repo
299            .update_document("/test/doc.md", &created)
300            .expect("Failed to update document");
301
302        assert_eq!(updated.title, "Updated Title");
303        assert_eq!(updated.updated_at, 1609462800.0);
304    }
305
306    #[test]
307    fn test_delete_document() {
308        let mut repo = setup_test_repository();
309
310        let new_doc = create_test_document();
311        repo.create_document(new_doc)
312            .expect("Failed to create document");
313
314        // Delete the document
315        let deleted = repo
316            .delete_document("/test/doc.md")
317            .expect("Failed to delete document");
318        assert!(deleted);
319
320        // Verify it's gone
321        let found = repo
322            .find_by_filepath("/test/doc.md")
323            .expect("Failed to search for document");
324        assert!(found.is_none());
325
326        // Try to delete non-existent document
327        let deleted_again = repo
328            .delete_document("/test/doc.md")
329            .expect("Failed to delete document");
330        assert!(!deleted_again);
331    }
332
333    #[test]
334    fn test_document_relationships() {
335        let mut repo = setup_test_repository();
336
337        // Create parent document
338        let parent_doc = NewDocument {
339            filepath: "/parent.md".to_string(),
340            id: "parent-1".to_string(),
341            title: "Parent Document".to_string(),
342            document_type: "strategy".to_string(),
343            created_at: 1609459200.0,
344            updated_at: 1609459200.0,
345            archived: false,
346            exit_criteria_met: false,
347            file_hash: "parent123".to_string(),
348            frontmatter_json: "{}".to_string(),
349            content: Some("Parent content".to_string()),
350            phase: "shaping".to_string(),
351            strategy_id: None,
352            initiative_id: None,
353        };
354        repo.create_document(parent_doc)
355            .expect("Failed to create parent");
356
357        // Create child document
358        let child_doc = NewDocument {
359            filepath: "/child.md".to_string(),
360            id: "child-1".to_string(),
361            title: "Child Document".to_string(),
362            document_type: "initiative".to_string(),
363            created_at: 1609459200.0,
364            updated_at: 1609459200.0,
365            archived: false,
366            exit_criteria_met: false,
367            file_hash: "child123".to_string(),
368            frontmatter_json: "{}".to_string(),
369            content: Some("Child content".to_string()),
370            phase: "discovery".to_string(),
371            strategy_id: Some("parent-1".to_string()),
372            initiative_id: None,
373        };
374        repo.create_document(child_doc)
375            .expect("Failed to create child");
376
377        // Create relationship
378        let relationship = DocumentRelationship {
379            child_id: "child-1".to_string(),
380            parent_id: "parent-1".to_string(),
381            child_filepath: "/child.md".to_string(),
382            parent_filepath: "/parent.md".to_string(),
383        };
384        repo.create_relationship(relationship)
385            .expect("Failed to create relationship");
386
387        // Test find children
388        let children = repo
389            .find_children("parent-1")
390            .expect("Failed to find children");
391        assert_eq!(children.len(), 1);
392        assert_eq!(children[0].id, "child-1");
393
394        // Test find parent
395        let parent = repo
396            .find_parent("child-1")
397            .expect("Failed to find parent")
398            .expect("Parent not found");
399        assert_eq!(parent.id, "parent-1");
400    }
401
402    #[test]
403    fn test_find_by_type() {
404        let mut repo = setup_test_repository();
405
406        // Create documents of different types
407        let vision_doc = NewDocument {
408            document_type: "vision".to_string(),
409            filepath: "/vision.md".to_string(),
410            id: "vision-1".to_string(),
411            title: "Vision Doc".to_string(),
412            created_at: 1609459200.0,
413            updated_at: 1609459200.0,
414            archived: false,
415            exit_criteria_met: false,
416            file_hash: "vision123".to_string(),
417            frontmatter_json: "{}".to_string(),
418            content: None,
419            phase: "draft".to_string(),
420            strategy_id: None,
421            initiative_id: None,
422        };
423
424        let strategy_doc = NewDocument {
425            document_type: "strategy".to_string(),
426            filepath: "/strategy.md".to_string(),
427            id: "strategy-1".to_string(),
428            title: "Strategy Doc".to_string(),
429            created_at: 1609462800.0, // Later timestamp
430            updated_at: 1609462800.0,
431            archived: false,
432            exit_criteria_met: false,
433            file_hash: "strategy123".to_string(),
434            frontmatter_json: "{}".to_string(),
435            content: None,
436            phase: "shaping".to_string(),
437            strategy_id: None,
438            initiative_id: None,
439        };
440
441        repo.create_document(vision_doc)
442            .expect("Failed to create vision");
443        repo.create_document(strategy_doc)
444            .expect("Failed to create strategy");
445
446        // Test find by type
447        let visions = repo.find_by_type("vision").expect("Failed to find visions");
448        assert_eq!(visions.len(), 1);
449        assert_eq!(visions[0].document_type, "vision");
450
451        let strategies = repo
452            .find_by_type("strategy")
453            .expect("Failed to find strategies");
454        assert_eq!(strategies.len(), 1);
455        assert_eq!(strategies[0].document_type, "strategy");
456
457        // Verify ordering (newest first)
458        let _all_docs = repo.find_by_type("vision").expect("Failed to find docs");
459        // Since we only have one vision, we can't test ordering here
460        // But the query should work
461    }
462
463    #[test]
464    fn test_document_not_found() {
465        let mut repo = setup_test_repository();
466
467        let found = repo
468            .find_by_filepath("/nonexistent.md")
469            .expect("Failed to search for document");
470        assert!(found.is_none());
471
472        let found_by_id = repo
473            .find_by_id("nonexistent")
474            .expect("Failed to search for document");
475        assert!(found_by_id.is_none());
476    }
477}