1use 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
13pub struct FrontmatterSqlEngine {
18 manager: Arc<VaultManager>,
19}
20
21pub 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 #[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 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 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 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 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 #[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 #[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 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 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
259fn 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
279fn exec(glue: &mut Glue<MemoryStorage>, sql: &str) -> Result<Vec<Payload>> {
281 exec_with(glue, sql, Vec::new())
282}
283
284fn 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); } 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 #[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}