1use 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
16pub const MAX_RESULT_ROWS: usize = 1000;
19
20pub const QUERY_TIMEOUT: Duration = Duration::from_secs(10);
26
27pub struct FrontmatterSqlEngine {
32 manager: Arc<VaultManager>,
33}
34
35pub struct SqlSession {
40 glue: Glue<MemoryStorage>,
41 pub file_count: usize,
43 pub tag_count: usize,
45 pub link_count: usize,
47}
48
49impl FrontmatterSqlEngine {
50 pub fn new(manager: Arc<VaultManager>) -> Self {
52 Self { manager }
53 }
54
55 #[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 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 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 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 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 #[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 #[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 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 pub async fn query(&mut self, sql: &str) -> Result<Value> {
257 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
303fn 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
334fn 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
357fn exec(glue: &mut Glue<MemoryStorage>, sql: &str) -> Result<Vec<Payload>> {
359 exec_with(glue, sql, Vec::new())
360}
361
362fn 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); } 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 #[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}