Skip to main content

turbovault_sql/
engine.rs

1//! Core SQL engine: session management, table building, query execution
2
3use crate::convert::{json_type_name, payload_to_json};
4use gluesql::params;
5use gluesql::prelude::{Glue, MemoryStorage, ParamLiteral, Payload};
6use serde_json::{Value, json};
7use std::collections::HashMap;
8use std::sync::Arc;
9use tracing::instrument;
10use turbovault_core::prelude::*;
11use turbovault_vault::VaultManager;
12
13/// SQL-based frontmatter query engine backed by GlueSQL.
14///
15/// Use [`query`](Self::query) for one-shot queries or
16/// [`session`](Self::session) to build tables once and run many queries.
17pub struct FrontmatterSqlEngine {
18    manager: Arc<VaultManager>,
19}
20
21/// A pre-built SQL session with `files`, `tags`, and `links` tables.
22///
23/// Created via [`FrontmatterSqlEngine::session`]. Reuse for multiple
24/// queries to avoid rebuilding the in-memory tables each time.
25pub struct SqlSession {
26    glue: Glue<MemoryStorage>,
27    pub file_count: usize,
28    pub tag_count: usize,
29    pub link_count: usize,
30}
31
32impl FrontmatterSqlEngine {
33    pub fn new(manager: Arc<VaultManager>) -> Self {
34        Self { manager }
35    }
36
37    /// Build all tables and return a reusable session.
38    #[instrument(skip(self), name = "sql_session_build")]
39    pub async fn session(&self) -> Result<SqlSession> {
40        let storage = MemoryStorage::default();
41        let mut glue = Glue::new(storage);
42
43        // Create all three tables
44        exec(&mut glue, "CREATE TABLE files")?;
45        exec(&mut glue, "CREATE TABLE tags (path TEXT, tag TEXT)")?;
46        exec(
47            &mut glue,
48            "CREATE TABLE links (source TEXT, target TEXT, link_type TEXT, is_valid BOOLEAN)",
49        )?;
50
51        let files = self.manager.scan_vault().await?;
52        let mut file_count = 0usize;
53        let mut tag_count = 0usize;
54
55        for file_path in &files {
56            if !file_path.to_string_lossy().to_lowercase().ends_with(".md") {
57                continue;
58            }
59
60            let vault_file = match self.manager.parse_file(file_path).await {
61                Ok(vf) => vf,
62                Err(_) => continue,
63            };
64
65            file_count += 1;
66
67            let rel_path = self.manager.relative_path(file_path);
68
69            // --- files table (schemaless JSON) ---
70            let mut row = serde_json::Map::new();
71            row.insert("path".to_string(), json!(rel_path));
72
73            if let Some(fm) = &vault_file.frontmatter {
74                for (key, value) in &fm.data {
75                    row.insert(key.clone(), value.clone());
76                }
77
78                // --- tags table (unnested from frontmatter) ---
79                if let Some(tags_val) = fm.data.get("tags") {
80                    let tag_strings = extract_tag_strings(tags_val);
81                    for tag in &tag_strings {
82                        let inserted = exec_with(
83                            &mut glue,
84                            "INSERT INTO tags VALUES ($1, $2)",
85                            params![rel_path.as_str(), tag.as_str()],
86                        );
87                        if let Err(e) = inserted {
88                            log::warn!("Tag insert error for {rel_path}: {e}");
89                        } else {
90                            tag_count += 1;
91                        }
92                    }
93                }
94            }
95
96            let json_str = serde_json::to_string(&Value::Object(row))
97                .map_err(|e| Error::config_error(format!("JSON serialization error: {e}")))?;
98            let inserted = exec_with(
99                &mut glue,
100                "INSERT INTO files VALUES ($1)",
101                params![json_str],
102            );
103            if let Err(e) = inserted {
104                log::warn!("Skipping {rel_path}: insert error: {e}");
105            }
106        }
107
108        // --- links table (from link graph) ---
109        let link_count = self.populate_links(&mut glue).await;
110
111        Ok(SqlSession {
112            glue,
113            file_count,
114            tag_count,
115            link_count,
116        })
117    }
118
119    /// One-shot: build tables, execute SQL, discard.
120    #[instrument(skip(self), fields(sql = sql), name = "sql_query")]
121    pub async fn query(&self, sql: &str) -> Result<Value> {
122        let mut session = self.session().await?;
123        session.query(sql).await
124    }
125
126    /// Inspect the frontmatter schema across all vault files.
127    #[instrument(skip(self), name = "sql_inspect")]
128    pub async fn inspect(&self) -> Result<Value> {
129        let files = self.manager.scan_vault().await?;
130        let mut schema: HashMap<String, SchemaInfo> = HashMap::new();
131        let mut file_count = 0usize;
132        let mut sample_paths: Vec<String> = Vec::new();
133
134        for file_path in &files {
135            if !file_path.to_string_lossy().to_lowercase().ends_with(".md") {
136                continue;
137            }
138
139            let vault_file = match self.manager.parse_file(file_path).await {
140                Ok(vf) => vf,
141                Err(_) => continue,
142            };
143
144            file_count += 1;
145
146            if sample_paths.len() < 3 {
147                sample_paths.push(self.manager.relative_path(file_path));
148            }
149
150            if let Some(fm) = &vault_file.frontmatter {
151                for (key, value) in &fm.data {
152                    let info = schema.entry(key.clone()).or_insert_with(|| SchemaInfo {
153                        type_name: "null".to_string(),
154                        count: 0,
155                        nullable: true,
156                    });
157                    info.count += 1;
158                    let observed = json_type_name(value);
159                    if info.type_name == "null" {
160                        info.type_name = observed.to_string();
161                    } else if info.type_name != observed && observed != "null" {
162                        info.type_name = "mixed".to_string();
163                    }
164                }
165            }
166        }
167
168        for info in schema.values_mut() {
169            info.nullable = info.count < file_count;
170        }
171
172        let mut schema_json = serde_json::Map::new();
173        schema_json.insert(
174            "path".to_string(),
175            json!({"type": "string", "nullable": false, "count": file_count}),
176        );
177        for (key, info) in &schema {
178            schema_json.insert(
179                key.clone(),
180                json!({
181                    "type": info.type_name,
182                    "nullable": info.nullable,
183                    "count": info.count
184                }),
185            );
186        }
187
188        Ok(json!({
189            "file_count": file_count,
190            "column_count": schema_json.len(),
191            "schema": schema_json,
192            "tables": {
193                "files": "Schemaless — one row per note with path + all frontmatter keys as columns",
194                "tags": "Structured (path TEXT, tag TEXT) — unnested from frontmatter tags arrays",
195                "links": "Structured (source TEXT, target TEXT, link_type TEXT, is_valid BOOLEAN) — from vault link graph"
196            },
197            "sample_paths": sample_paths,
198            "usage": "Call query_frontmatter_sql with SQL against the files, tags, or links tables"
199        }))
200    }
201
202    /// Populate the `links` table from the vault link graph.
203    async fn populate_links(&self, glue: &mut Glue<MemoryStorage>) -> usize {
204        let graph = self.manager.link_graph();
205        let graph_read = graph.read().await;
206        let all_links = graph_read.all_links();
207        let mut count = 0usize;
208
209        for (source_path, links) in &all_links {
210            let source_rel = self.manager.relative_path(source_path);
211
212            for link in links {
213                let inserted = exec_with(
214                    glue,
215                    "INSERT INTO links VALUES ($1, $2, $3, $4)",
216                    params![
217                        source_rel.as_str(),
218                        link.target.as_str(),
219                        format!("{:?}", link.type_),
220                        link.is_valid
221                    ],
222                );
223                if inserted.is_ok() {
224                    count += 1;
225                }
226            }
227        }
228
229        count
230    }
231}
232
233impl SqlSession {
234    /// Execute a SQL query against the pre-built tables.
235    pub async fn query(&mut self, sql: &str) -> Result<Value> {
236        let payloads = exec(&mut self.glue, sql)?;
237
238        let result = if payloads.len() == 1 {
239            payload_to_json(payloads.into_iter().next().unwrap())
240        } else {
241            Value::Array(payloads.into_iter().map(payload_to_json).collect())
242        };
243
244        Ok(json!({
245            "file_count": self.file_count,
246            "tag_count": self.tag_count,
247            "link_count": self.link_count,
248            "result": result
249        }))
250    }
251}
252
253struct SchemaInfo {
254    type_name: String,
255    count: usize,
256    nullable: bool,
257}
258
259/// Extract tag strings from a frontmatter value (handles arrays and comma-separated strings).
260fn extract_tag_strings(value: &Value) -> Vec<String> {
261    match value {
262        Value::Array(arr) => arr
263            .iter()
264            .filter_map(|v| v.as_str())
265            .map(|s| s.strip_prefix('#').unwrap_or(s).to_string())
266            .collect(),
267        Value::String(s) => s
268            .split(',')
269            .map(|t| {
270                let trimmed = t.trim();
271                trimmed.strip_prefix('#').unwrap_or(trimmed).to_string()
272            })
273            .filter(|t| !t.is_empty())
274            .collect(),
275        _ => vec![],
276    }
277}
278
279/// Execute a SQL statement, mapping errors to `turbovault_core::Error`.
280fn exec(glue: &mut Glue<MemoryStorage>, sql: &str) -> Result<Vec<Payload>> {
281    exec_with(glue, sql, Vec::new())
282}
283
284/// Execute a SQL statement with `$1`, `$2`, ... bound to `params`.
285///
286/// Every value that comes from the vault goes through here rather than being
287/// spliced into the SQL text. A note path, tag, or link target is arbitrary
288/// text, and quoting it by hand is one missed character away from being read
289/// as SQL.
290fn exec_with(
291    glue: &mut Glue<MemoryStorage>,
292    sql: &str,
293    params: Vec<ParamLiteral>,
294) -> Result<Vec<Payload>> {
295    glue.execute_with_params(sql, params)
296        .map_err(|e| Error::config_error(format!("SQL error: {e}")))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_schemaless_roundtrip() {
305        let storage = MemoryStorage::default();
306        let mut glue = Glue::new(storage);
307
308        exec(&mut glue, "CREATE TABLE test").unwrap();
309        exec(
310            &mut glue,
311            r#"INSERT INTO test VALUES ('{"path": "note.md", "status": "active", "priority": 3}')"#,
312        )
313        .unwrap();
314        exec(
315            &mut glue,
316            r#"INSERT INTO test VALUES ('{"path": "other.md", "status": "draft"}')"#,
317        )
318        .unwrap();
319
320        let payloads = glue
321            .execute("SELECT path, status FROM test WHERE status = 'active'")
322            .unwrap();
323
324        assert_eq!(payloads.len(), 1);
325        if let Payload::Select { labels, rows } = &payloads[0] {
326            assert_eq!(labels, &["path", "status"]);
327            assert_eq!(rows.len(), 1);
328        } else {
329            panic!("Expected Select payload");
330        }
331    }
332
333    #[test]
334    fn test_aggregation() {
335        let storage = MemoryStorage::default();
336        let mut glue = Glue::new(storage);
337
338        exec(&mut glue, "CREATE TABLE test").unwrap();
339        exec(
340            &mut glue,
341            r#"INSERT INTO test VALUES ('{"status": "active"}')"#,
342        )
343        .unwrap();
344        exec(
345            &mut glue,
346            r#"INSERT INTO test VALUES ('{"status": "active"}')"#,
347        )
348        .unwrap();
349        exec(
350            &mut glue,
351            r#"INSERT INTO test VALUES ('{"status": "draft"}')"#,
352        )
353        .unwrap();
354
355        let payloads = glue
356            .execute("SELECT status, COUNT(*) as cnt FROM test GROUP BY status ORDER BY cnt DESC")
357            .unwrap();
358
359        if let Payload::Select { rows, .. } = &payloads[0] {
360            assert_eq!(rows.len(), 2);
361        } else {
362            panic!("Expected Select payload");
363        }
364    }
365
366    #[test]
367    fn test_structured_tags_table() {
368        let storage = MemoryStorage::default();
369        let mut glue = Glue::new(storage);
370
371        exec(&mut glue, "CREATE TABLE tags (path TEXT, tag TEXT)").unwrap();
372        exec(&mut glue, "INSERT INTO tags VALUES ('note.md', 'work')").unwrap();
373        exec(
374            &mut glue,
375            "INSERT INTO tags VALUES ('note.md', 'important')",
376        )
377        .unwrap();
378        exec(&mut glue, "INSERT INTO tags VALUES ('other.md', 'work')").unwrap();
379
380        let payloads = glue
381            .execute("SELECT tag, COUNT(*) as cnt FROM tags GROUP BY tag ORDER BY cnt DESC")
382            .unwrap();
383
384        if let Payload::Select { labels, rows } = &payloads[0] {
385            assert_eq!(labels, &["tag", "cnt"]);
386            assert_eq!(rows.len(), 2); // work=2, important=1
387        } else {
388            panic!("Expected Select payload");
389        }
390    }
391
392    #[test]
393    fn test_join_files_and_tags() {
394        let storage = MemoryStorage::default();
395        let mut glue = Glue::new(storage);
396
397        exec(&mut glue, "CREATE TABLE files").unwrap();
398        exec(&mut glue, "CREATE TABLE tags (path TEXT, tag TEXT)").unwrap();
399
400        exec(
401            &mut glue,
402            r#"INSERT INTO files VALUES ('{"path": "note.md", "status": "active"}')"#,
403        )
404        .unwrap();
405        exec(
406            &mut glue,
407            r#"INSERT INTO files VALUES ('{"path": "other.md", "status": "draft"}')"#,
408        )
409        .unwrap();
410        exec(&mut glue, "INSERT INTO tags VALUES ('note.md', 'work')").unwrap();
411
412        let payloads = glue
413            .execute(
414                "SELECT f.path, f.status FROM files f JOIN tags t ON f.path = t.path WHERE t.tag = 'work'",
415            )
416            .unwrap();
417
418        if let Payload::Select { rows, .. } = &payloads[0] {
419            assert_eq!(rows.len(), 1);
420        } else {
421            panic!("Expected Select payload");
422        }
423    }
424
425    #[test]
426    fn test_links_table() {
427        let storage = MemoryStorage::default();
428        let mut glue = Glue::new(storage);
429
430        exec(
431            &mut glue,
432            "CREATE TABLE links (source TEXT, target TEXT, link_type TEXT, is_valid BOOLEAN)",
433        )
434        .unwrap();
435        exec(
436            &mut glue,
437            "INSERT INTO links VALUES ('note.md', 'other.md', 'WikiLink', true)",
438        )
439        .unwrap();
440        exec(
441            &mut glue,
442            "INSERT INTO links VALUES ('note.md', 'missing.md', 'WikiLink', false)",
443        )
444        .unwrap();
445
446        let payloads = glue
447            .execute("SELECT source, target FROM links WHERE is_valid = false")
448            .unwrap();
449
450        if let Payload::Select { rows, .. } = &payloads[0] {
451            assert_eq!(rows.len(), 1);
452        } else {
453            panic!("Expected Select payload");
454        }
455    }
456
457    /// Vault text reaches every table as a bound value, so a quote in a note's
458    /// path or SQL in a tag comes back as the text it was, not as a statement.
459    #[test]
460    fn test_bound_values_are_stored_as_text() {
461        let mut glue = Glue::new(MemoryStorage::default());
462        exec(&mut glue, "CREATE TABLE files").unwrap();
463        exec(&mut glue, "CREATE TABLE tags (path TEXT, tag TEXT)").unwrap();
464
465        let path = "it's a note.md";
466        let tag = "x'); DROP TABLE files; --";
467        let row = serde_json::to_string(&json!({ "path": path, "status": "o'k" })).unwrap();
468        exec_with(&mut glue, "INSERT INTO files VALUES ($1)", params![row]).unwrap();
469        exec_with(
470            &mut glue,
471            "INSERT INTO tags VALUES ($1, $2)",
472            params![path, tag],
473        )
474        .unwrap();
475
476        let files = exec(&mut glue, "SELECT path, status FROM files").unwrap();
477        assert_eq!(
478            payload_to_json(files.into_iter().next().unwrap())["rows"],
479            json!([{ "path": path, "status": "o'k" }])
480        );
481        let tags = exec(&mut glue, "SELECT path, tag FROM tags").unwrap();
482        assert_eq!(
483            payload_to_json(tags.into_iter().next().unwrap())["rows"],
484            json!([{ "path": path, "tag": tag }])
485        );
486    }
487
488    #[test]
489    fn test_extract_tag_strings_array() {
490        let val = json!(["#work", "personal", "#urgent"]);
491        let tags = extract_tag_strings(&val);
492        assert_eq!(tags, vec!["work", "personal", "urgent"]);
493    }
494
495    #[test]
496    fn test_extract_tag_strings_csv() {
497        let val = json!("#work, personal, #urgent");
498        let tags = extract_tag_strings(&val);
499        assert_eq!(tags, vec!["work", "personal", "urgent"]);
500    }
501
502    #[test]
503    fn test_extract_tag_strings_empty() {
504        assert!(extract_tag_strings(&json!(null)).is_empty());
505        assert!(extract_tag_strings(&json!(42)).is_empty());
506    }
507}