1use gonzalo_graph::{
17 CodeGraph, FromScope, GraphStore, Import, Located, RefKind, Reference, Symbol,
18};
19use rusqlite::{Connection, params};
20use std::path::Path;
21use std::sync::Mutex;
22
23const SCHEMA: &str = "
24CREATE TABLE IF NOT EXISTS symbols (
25 path TEXT NOT NULL,
26 name TEXT NOT NULL,
27 kind TEXT NOT NULL,
28 start_line INTEGER NOT NULL,
29 end_line INTEGER NOT NULL,
30 owner TEXT
31);
32CREATE TABLE IF NOT EXISTS refs (
33 path TEXT NOT NULL,
34 name TEXT NOT NULL,
35 from_fn TEXT,
36 line INTEGER NOT NULL,
37 kind TEXT NOT NULL DEFAULT 'free',
38 qualifier TEXT,
39 from_scope TEXT NOT NULL DEFAULT 'function'
40);
41CREATE TABLE IF NOT EXISTS imports (
42 path TEXT NOT NULL,
43 name TEXT NOT NULL,
44 module TEXT NOT NULL,
45 depth INTEGER NOT NULL DEFAULT 0,
46 line INTEGER NOT NULL
47);
48CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
49CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
50CREATE INDEX IF NOT EXISTS idx_refs_name ON refs(name);
51CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_fn);
52CREATE INDEX IF NOT EXISTS idx_refs_path ON refs(path);
53CREATE INDEX IF NOT EXISTS idx_imports_path ON imports(path);
54";
55
56pub struct SqliteGraphStore {
58 conn: Mutex<Connection>,
59}
60
61impl SqliteGraphStore {
62 pub fn open(path: impl AsRef<Path>) -> rusqlite::Result<Self> {
65 let path = path.as_ref();
66 if let Some(parent) = path.parent() {
67 let _ = std::fs::create_dir_all(parent);
69 }
70 Self::init(Connection::open(path)?)
71 }
72
73 pub fn open_in_memory() -> rusqlite::Result<Self> {
75 Self::init(Connection::open_in_memory()?)
76 }
77
78 fn init(conn: Connection) -> rusqlite::Result<Self> {
79 conn.execute_batch(SCHEMA)?;
80 Self::migrate(&conn)?;
81 Ok(Self {
82 conn: Mutex::new(conn),
83 })
84 }
85
86 fn migrate(conn: &Connection) -> rusqlite::Result<()> {
93 let has = |table: &str, column: &str| -> rusqlite::Result<bool> {
94 conn.prepare(&format!(
95 "SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}'"
96 ))?
97 .exists([])
98 };
99 if !has("refs", "kind")? {
100 conn.execute_batch("ALTER TABLE refs ADD COLUMN kind TEXT NOT NULL DEFAULT 'free'")?;
101 }
102 if !has("symbols", "owner")? {
106 conn.execute_batch("ALTER TABLE symbols ADD COLUMN owner TEXT")?;
107 }
108 if !has("refs", "qualifier")? {
109 conn.execute_batch("ALTER TABLE refs ADD COLUMN qualifier TEXT")?;
110 }
111 if !has("refs", "from_scope")? {
116 conn.execute_batch(
117 "ALTER TABLE refs ADD COLUMN from_scope TEXT NOT NULL DEFAULT 'function'",
118 )?;
119 }
120 if !has("imports", "depth")? {
121 conn.execute_batch("ALTER TABLE imports ADD COLUMN depth INTEGER NOT NULL DEFAULT 0")?;
122 }
123 Ok(())
124 }
125
126 pub fn remove_path(&mut self, path: &str) {
129 let guard = self.conn.lock().expect("connection poisoned");
130 guard
131 .execute("DELETE FROM symbols WHERE path = ?1", params![path])
132 .expect("delete symbols for path");
133 guard
134 .execute("DELETE FROM refs WHERE path = ?1", params![path])
135 .expect("delete refs for path");
136 guard
137 .execute("DELETE FROM imports WHERE path = ?1", params![path])
138 .expect("delete imports for path");
139 }
140}
141
142pub fn view_db_path(graph_root: &Path, repo: &str, view_id: &str) -> std::path::PathBuf {
146 graph_root
147 .join(fs_safe(repo))
148 .join(format!("{}.db", fs_safe(view_id)))
149}
150
151fn fs_safe(s: &str) -> String {
163 gonzalo_core::segment(s)
164}
165
166fn join_module(segments: &[String]) -> String {
170 segments.join("/")
171}
172
173fn split_module(raw: &str) -> Vec<String> {
174 if raw.is_empty() {
175 return Vec::new();
176 }
177 raw.split('/').map(str::to_string).collect()
178}
179
180fn kind_to_text(sym: &Symbol) -> String {
181 serde_json::to_string(&sym.kind).expect("SymbolKind serializes")
182}
183
184fn symbol_from_row(row: &rusqlite::Row, base: usize) -> rusqlite::Result<Symbol> {
185 let name: String = row.get(base)?;
186 let kind_text: String = row.get(base + 1)?;
187 let start: i64 = row.get(base + 2)?;
188 let end: i64 = row.get(base + 3)?;
189 Ok(Symbol {
190 name,
191 kind: serde_json::from_str(&kind_text).expect("stored SymbolKind is valid"),
192 start_line: start as usize,
193 end_line: end as usize,
194 owner: row.get(base + 4)?,
195 })
196}
197
198impl GraphStore for SqliteGraphStore {
199 fn insert(&mut self, path: &str, graph: CodeGraph) {
200 let mut guard = self.conn.lock().expect("connection poisoned");
201 let tx = guard.transaction().expect("begin transaction");
202 tx.execute("DELETE FROM symbols WHERE path = ?1", params![path])
203 .expect("clear symbols for path");
204 tx.execute("DELETE FROM refs WHERE path = ?1", params![path])
205 .expect("clear refs for path");
206 tx.execute("DELETE FROM imports WHERE path = ?1", params![path])
207 .expect("clear imports for path");
208 for s in &graph.symbols {
209 tx.execute(
210 "INSERT INTO symbols (path, name, kind, start_line, end_line, owner)
211 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
212 params![
213 path,
214 s.name,
215 kind_to_text(s),
216 s.start_line as i64,
217 s.end_line as i64,
218 s.owner
219 ],
220 )
221 .expect("insert symbol");
222 }
223 for r in &graph.references {
224 tx.execute(
225 "INSERT INTO refs (path, name, from_fn, line, kind, qualifier, from_scope)
226 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
227 params![
228 path,
229 r.name,
230 r.from,
231 r.line as i64,
232 r.kind.as_str(),
233 r.qualifier,
234 r.from_scope.as_str()
235 ],
236 )
237 .expect("insert reference");
238 }
239 for i in &graph.imports {
240 tx.execute(
241 "INSERT INTO imports (path, name, module, depth, line)
242 VALUES (?1, ?2, ?3, ?4, ?5)",
243 params![
244 path,
245 i.name,
246 join_module(&i.path),
247 i.depth as i64,
248 i.line as i64
249 ],
250 )
251 .expect("insert import");
252 }
253 tx.commit().expect("commit transaction");
254 }
255
256 fn imports_in_file(&self, path: &str) -> Vec<Import> {
257 let guard = self.conn.lock().expect("connection poisoned");
258 let mut stmt = guard
259 .prepare("SELECT name, module, depth, line FROM imports WHERE path = ?1 ORDER BY line")
260 .expect("prepare imports_in_file");
261 let rows = stmt
262 .query_map(params![path], |row| {
263 Ok(Import {
264 name: row.get(0)?,
265 path: split_module(&row.get::<_, String>(1)?),
266 depth: row.get::<_, i64>(2)? as usize,
267 line: row.get::<_, i64>(3)? as usize,
268 })
269 })
270 .expect("query imports_in_file");
271 rows.collect::<rusqlite::Result<Vec<_>>>()
272 .expect("collect imports_in_file")
273 }
274
275 fn symbols_in_file(&self, path: &str) -> Vec<Symbol> {
276 let guard = self.conn.lock().expect("connection poisoned");
277 let mut stmt = guard
278 .prepare("SELECT name, kind, start_line, end_line, owner FROM symbols WHERE path = ?1")
279 .expect("prepare symbols_in_file");
280 let rows = stmt
281 .query_map(params![path], |row| symbol_from_row(row, 0))
282 .expect("query symbols_in_file");
283 rows.collect::<rusqlite::Result<Vec<_>>>()
284 .expect("collect symbols_in_file")
285 }
286
287 fn definitions(&self, name: &str) -> Vec<Located<Symbol>> {
288 let guard = self.conn.lock().expect("connection poisoned");
289 let mut stmt = guard
290 .prepare(
291 "SELECT path, name, kind, start_line, end_line, owner FROM symbols
292 WHERE name = ?1 ORDER BY path",
293 )
294 .expect("prepare definitions");
295 let rows = stmt
296 .query_map(params![name], |row| {
297 Ok(Located {
298 path: row.get(0)?,
299 item: symbol_from_row(row, 1)?,
300 })
301 })
302 .expect("query definitions");
303 rows.collect::<rusqlite::Result<Vec<_>>>()
304 .expect("collect definitions")
305 }
306
307 fn references_to(&self, name: &str) -> Vec<Located<Reference>> {
308 let guard = self.conn.lock().expect("connection poisoned");
309 let mut stmt = guard
310 .prepare(
311 "SELECT path, name, from_fn, line, kind, qualifier, from_scope FROM refs
312 WHERE name = ?1 ORDER BY path, line",
313 )
314 .expect("prepare references_to");
315 let rows = stmt
316 .query_map(params![name], |row| {
317 Ok(Located {
318 path: row.get(0)?,
319 item: Reference {
320 from_scope: FromScope::from_str_or_function(&row.get::<_, String>(6)?),
321 name: row.get(1)?,
322 from: row.get::<_, Option<String>>(2)?,
323 line: row.get::<_, i64>(3)? as usize,
324 kind: RefKind::from_str_or_free(&row.get::<_, String>(4)?),
325 qualifier: row.get(5)?,
326 },
327 })
328 })
329 .expect("query references_to");
330 rows.collect::<rusqlite::Result<Vec<_>>>()
331 .expect("collect references_to")
332 }
333
334 fn callers_of(&self, name: &str) -> Vec<String> {
335 let guard = self.conn.lock().expect("connection poisoned");
336 let mut stmt = guard
337 .prepare(
338 "SELECT DISTINCT from_fn FROM refs
339 WHERE name = ?1 AND from_fn IS NOT NULL AND kind != 'value'
340 ORDER BY from_fn",
341 )
342 .expect("prepare callers_of");
343 let rows = stmt
344 .query_map(params![name], |row| row.get::<_, String>(0))
345 .expect("query callers_of");
346 rows.collect::<rusqlite::Result<Vec<_>>>()
347 .expect("collect callers_of")
348 }
349
350 fn callees(&self, name: &str) -> Vec<String> {
351 let guard = self.conn.lock().expect("connection poisoned");
352 let mut stmt = guard
353 .prepare(
354 "SELECT DISTINCT name FROM refs
355 WHERE from_fn = ?1 AND kind != 'value' ORDER BY name",
356 )
357 .expect("prepare callees");
358 let rows = stmt
359 .query_map(params![name], |row| row.get::<_, String>(0))
360 .expect("query callees");
361 rows.collect::<rusqlite::Result<Vec<_>>>()
362 .expect("collect callees")
363 }
364
365 fn all_symbols(&self) -> Vec<Located<Symbol>> {
366 let guard = self.conn.lock().expect("connection poisoned");
367 let mut stmt = guard
368 .prepare(
369 "SELECT path, name, kind, start_line, end_line, owner FROM symbols ORDER BY path",
370 )
371 .expect("prepare all_symbols");
372 let rows = stmt
373 .query_map([], |row| {
374 Ok(Located {
375 path: row.get(0)?,
376 item: symbol_from_row(row, 1)?,
377 })
378 })
379 .expect("query all_symbols");
380 rows.collect::<rusqlite::Result<Vec<_>>>()
381 .expect("collect all_symbols")
382 }
383
384 fn all_references(&self) -> Vec<Located<Reference>> {
385 let guard = self.conn.lock().expect("connection poisoned");
386 let mut stmt = guard
387 .prepare(
388 "SELECT path, name, from_fn, line, kind, qualifier, from_scope FROM refs
389 ORDER BY path, line",
390 )
391 .expect("prepare all_references");
392 let rows = stmt
393 .query_map([], |row| {
394 Ok(Located {
395 path: row.get(0)?,
396 item: Reference {
397 from_scope: FromScope::from_str_or_function(&row.get::<_, String>(6)?),
398 name: row.get(1)?,
399 from: row.get::<_, Option<String>>(2)?,
400 line: row.get::<_, i64>(3)? as usize,
401 kind: RefKind::from_str_or_free(&row.get::<_, String>(4)?),
402 qualifier: row.get(5)?,
403 },
404 })
405 })
406 .expect("query all_references");
407 rows.collect::<rusqlite::Result<Vec<_>>>()
408 .expect("collect all_references")
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
422 fn view_db_path_is_injective_across_colliding_pairs() {
423 let root = Path::new("/graphs");
424 let a = view_db_path(root, "org/repo", "main");
425 let b = view_db_path(root, "org_repo", "main");
426 assert_ne!(a, b, "distinct repos must not collide onto one db file");
427
428 assert_eq!(a, Path::new("/graphs/org%2Frepo/main.db"));
431 assert_eq!(b, Path::new("/graphs/org_repo/main.db"));
432 assert!(a.extension().is_some_and(|e| e == "db"));
433
434 let c = view_db_path(root, "org/repo", "v1.0");
436 let d = view_db_path(root, "org/repo", "v1_0");
437 assert_ne!(c, d, "distinct views must not collide onto one db file");
438 }
439
440 #[test]
446 fn opening_a_pre_248_database_adds_the_new_columns() {
447 let dir = tempfile::tempdir().expect("tempdir");
448 let db = dir.path().join("old.db");
449 {
450 let conn = Connection::open(&db).expect("create old db");
451 conn.execute_batch(
452 "CREATE TABLE symbols (
453 path TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL,
454 start_line INTEGER NOT NULL, end_line INTEGER NOT NULL);
455 CREATE TABLE refs (
456 path TEXT NOT NULL, name TEXT NOT NULL, from_fn TEXT,
457 line INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT 'free');
458 INSERT INTO symbols VALUES ('old.rs', 'legacy', '\"function\"', 1, 2);",
459 )
460 .expect("seed a pre-248 schema");
461 }
462
463 let mut store = SqliteGraphStore::open(&db).expect("open upgrades in place");
464
465 assert_eq!(store.definitions("legacy")[0].item.owner, None);
467
468 store.insert(
470 "beta.rs",
471 gonzalo_graph::build_rust("struct Beta; impl Beta { fn get() {} }"),
472 );
473 store.insert(
474 "call.rs",
475 gonzalo_graph::build_rust("fn caller() { Beta::get(); }"),
476 );
477 assert_eq!(
478 store.definitions("get")[0].item.owner.as_deref(),
479 Some("Beta")
480 );
481 assert_eq!(
482 store.references_to("get")[0].item.qualifier.as_deref(),
483 Some("Beta")
484 );
485
486 store.insert(
490 "app.rs",
491 gonzalo_graph::build_rust("use crate::model::make;\nfn caller() { make(); }"),
492 );
493 let imports = store.imports_in_file("app.rs");
494 assert_eq!(imports.len(), 1, "{imports:?}");
495 assert_eq!(imports[0].path, vec!["model".to_string()]);
496 }
497}