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