Skip to main content

gonzalo_graph_sqlite/
lib.rs

1//! A persistent, SQLite-backed [`GraphStore`] (ticket B).
2//!
3//! Resolves the scalable-backend spike in favour of SQLite (`rusqlite`,
4//! bundled): FOSS/local, a natural fit for the sync [`GraphStore`] trait, and
5//! able to persist a view built once at index time rather than re-assembling it
6//! into memory on every query. Symbols and references live in two path-keyed
7//! tables; `insert` replaces a path's rows transactionally, so re-indexing a
8//! file never duplicates its symbols. `callees`/`impact` use the trait defaults.
9//!
10//! The `GraphStore` trait is infallible, so query methods `expect` on the
11//! embedded database — a failure there is corruption/programmer error, not a
12//! recoverable condition. The connection is held behind a `Mutex` (rusqlite's
13//! `Connection` is `Send` but not `Sync`, and `GraphStore` requires `Sync`);
14//! read concurrency via a connection pool is a follow-on.
15
16use 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
56/// A [`GraphStore`] backed by a SQLite database.
57pub struct SqliteGraphStore {
58    conn: Mutex<Connection>,
59}
60
61impl SqliteGraphStore {
62    /// Open (creating if absent, including parent directories) a file-backed
63    /// store at `path`.
64    pub fn open(path: impl AsRef<Path>) -> rusqlite::Result<Self> {
65        let path = path.as_ref();
66        if let Some(parent) = path.parent() {
67            // Best-effort: if this fails, `Connection::open` reports the real error.
68            let _ = std::fs::create_dir_all(parent);
69        }
70        Self::init(Connection::open(path)?)
71    }
72
73    /// Open an ephemeral in-memory store.
74    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    /// Bring an existing database up to the current schema.
87    ///
88    /// `CREATE TABLE IF NOT EXISTS` leaves an older `refs` table untouched, so a
89    /// database written before `refs.kind` existed needs the column added
90    /// explicitly. Rows already there default to `free`, which is exactly the
91    /// shape they were assumed to have (#223).
92    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        // #248's two columns are nullable with no default: a row written before
103        // them reads back as `None`, which is exactly "this file had neither",
104        // the shape those rows were assumed to have.
105        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        // A row written before #261 was an absolute import, which is depth 0 —
112        // exactly what the default gives it.
113        // A row written before #268 had a function for its `from`, which is
114        // exactly what the default gives it.
115        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    /// Remove all rows for `path` (a file dropped from the view). Complements
127    /// [`GraphStore::insert`], which replaces a path's rows.
128    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
142/// The database file for a view's graph under `graph_root`:
143/// `<graph_root>/<repo>/<view_id>.db`, with `repo`/`view_id` made
144/// filesystem-safe. Writer (indexer) and reader (server) must agree on this.
145pub 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
151/// Map an identifier to a single filesystem-safe path segment via the shared,
152/// **injective** percent-style encoder ([`gonzalo_core::segment`]): the
153/// unreserved set `[A-Za-z0-9_-]` survives verbatim and every other byte
154/// (including `/` and `.`) becomes `%XX`. Because the map is injective,
155/// distinct `(repo, view_id)` pairs can never collide onto one `.db` file (the
156/// old lossy `_`-collapse let `org/repo` and `org_repo` share a path). Escaping
157/// `.` and `/` also keeps a component from escaping `graph_root`, and since the
158/// encoded `view_id` contains no literal `.`, the only `.` in the filename is
159/// the trailing `.db` suffix. The mapping is a pure function so the writer
160/// (indexer) and reader (server) always agree; the path is never parsed back
161/// into `repo`/`view_id`, so no decode is needed here.
162fn fs_safe(s: &str) -> String {
163    gonzalo_core::segment(s)
164}
165
166/// A [`SymbolKind`](gonzalo_graph::SymbolKind) as stored TEXT (its serde form).
167/// Module segments as one column value. `/` cannot appear in an identifier, so
168/// the join is unambiguous and splits back exactly.
169fn 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    /// Regression for #132: distinct `(repo, view)` pairs that collided under
417    /// the old lossy `_`-collapse must now map to different db files. Under the
418    /// old scheme both `("org/repo","main")` and `("org_repo","main")` produced
419    /// `<root>/org_repo/main.db`; one view's graph would serve/overwrite the
420    /// other. The injective encoder keeps them apart.
421    #[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        // The `/` is percent-escaped, so `repo` stays a single non-escaping
429        // segment and the filename's only `.` is the `.db` suffix.
430        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        // Collisions in the view_id component are likewise avoided.
435        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    /// A database written before #248 has neither new column. `CREATE TABLE IF
441    /// NOT EXISTS` leaves an existing table untouched, so opening one must add
442    /// them — otherwise every insert fails against a view that indexed fine
443    /// yesterday, which is the failure mode the `refs.kind` migration was
444    /// written for in #223.
445    #[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        // A row written before the column existed reads as "this file had none".
466        assert_eq!(store.definitions("legacy")[0].item.owner, None);
467
468        // And a fresh insert into the upgraded table round-trips both fields.
469        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        // The imports table did not exist at all in that database; opening it
487        // creates it, so import-aware resolution works on an upgraded view
488        // rather than silently finding nothing (#252).
489        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}