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::{CodeGraph, GraphStore, Located, RefKind, Reference, Symbol};
17use rusqlite::{Connection, params};
18use std::path::Path;
19use std::sync::Mutex;
20
21const SCHEMA: &str = "
22CREATE TABLE IF NOT EXISTS symbols (
23    path       TEXT    NOT NULL,
24    name       TEXT    NOT NULL,
25    kind       TEXT    NOT NULL,
26    start_line INTEGER NOT NULL,
27    end_line   INTEGER NOT NULL
28);
29CREATE TABLE IF NOT EXISTS refs (
30    path    TEXT    NOT NULL,
31    name    TEXT    NOT NULL,
32    from_fn TEXT,
33    line    INTEGER NOT NULL,
34    kind    TEXT    NOT NULL DEFAULT 'free'
35);
36CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
37CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
38CREATE INDEX IF NOT EXISTS idx_refs_name    ON refs(name);
39CREATE INDEX IF NOT EXISTS idx_refs_from    ON refs(from_fn);
40CREATE INDEX IF NOT EXISTS idx_refs_path    ON refs(path);
41";
42
43/// A [`GraphStore`] backed by a SQLite database.
44pub struct SqliteGraphStore {
45    conn: Mutex<Connection>,
46}
47
48impl SqliteGraphStore {
49    /// Open (creating if absent, including parent directories) a file-backed
50    /// store at `path`.
51    pub fn open(path: impl AsRef<Path>) -> rusqlite::Result<Self> {
52        let path = path.as_ref();
53        if let Some(parent) = path.parent() {
54            // Best-effort: if this fails, `Connection::open` reports the real error.
55            let _ = std::fs::create_dir_all(parent);
56        }
57        Self::init(Connection::open(path)?)
58    }
59
60    /// Open an ephemeral in-memory store.
61    pub fn open_in_memory() -> rusqlite::Result<Self> {
62        Self::init(Connection::open_in_memory()?)
63    }
64
65    fn init(conn: Connection) -> rusqlite::Result<Self> {
66        conn.execute_batch(SCHEMA)?;
67        Self::migrate(&conn)?;
68        Ok(Self {
69            conn: Mutex::new(conn),
70        })
71    }
72
73    /// Bring an existing database up to the current schema.
74    ///
75    /// `CREATE TABLE IF NOT EXISTS` leaves an older `refs` table untouched, so a
76    /// database written before `refs.kind` existed needs the column added
77    /// explicitly. Rows already there default to `free`, which is exactly the
78    /// shape they were assumed to have (#223).
79    fn migrate(conn: &Connection) -> rusqlite::Result<()> {
80        let has_kind = conn
81            .prepare("SELECT 1 FROM pragma_table_info('refs') WHERE name = 'kind'")?
82            .exists([])?;
83        if !has_kind {
84            conn.execute_batch("ALTER TABLE refs ADD COLUMN kind TEXT NOT NULL DEFAULT 'free'")?;
85        }
86        Ok(())
87    }
88
89    /// Remove all rows for `path` (a file dropped from the view). Complements
90    /// [`GraphStore::insert`], which replaces a path's rows.
91    pub fn remove_path(&mut self, path: &str) {
92        let guard = self.conn.lock().expect("connection poisoned");
93        guard
94            .execute("DELETE FROM symbols WHERE path = ?1", params![path])
95            .expect("delete symbols for path");
96        guard
97            .execute("DELETE FROM refs WHERE path = ?1", params![path])
98            .expect("delete refs for path");
99    }
100}
101
102/// The database file for a view's graph under `graph_root`:
103/// `<graph_root>/<repo>/<view_id>.db`, with `repo`/`view_id` made
104/// filesystem-safe. Writer (indexer) and reader (server) must agree on this.
105pub fn view_db_path(graph_root: &Path, repo: &str, view_id: &str) -> std::path::PathBuf {
106    graph_root
107        .join(fs_safe(repo))
108        .join(format!("{}.db", fs_safe(view_id)))
109}
110
111/// Map an identifier to a single filesystem-safe path segment via the shared,
112/// **injective** percent-style encoder ([`gonzalo_core::segment`]): the
113/// unreserved set `[A-Za-z0-9_-]` survives verbatim and every other byte
114/// (including `/` and `.`) becomes `%XX`. Because the map is injective,
115/// distinct `(repo, view_id)` pairs can never collide onto one `.db` file (the
116/// old lossy `_`-collapse let `org/repo` and `org_repo` share a path). Escaping
117/// `.` and `/` also keeps a component from escaping `graph_root`, and since the
118/// encoded `view_id` contains no literal `.`, the only `.` in the filename is
119/// the trailing `.db` suffix. The mapping is a pure function so the writer
120/// (indexer) and reader (server) always agree; the path is never parsed back
121/// into `repo`/`view_id`, so no decode is needed here.
122fn fs_safe(s: &str) -> String {
123    gonzalo_core::segment(s)
124}
125
126/// A [`SymbolKind`](gonzalo_graph::SymbolKind) as stored TEXT (its serde form).
127fn kind_to_text(sym: &Symbol) -> String {
128    serde_json::to_string(&sym.kind).expect("SymbolKind serializes")
129}
130
131fn symbol_from_row(row: &rusqlite::Row, base: usize) -> rusqlite::Result<Symbol> {
132    let name: String = row.get(base)?;
133    let kind_text: String = row.get(base + 1)?;
134    let start: i64 = row.get(base + 2)?;
135    let end: i64 = row.get(base + 3)?;
136    Ok(Symbol {
137        name,
138        kind: serde_json::from_str(&kind_text).expect("stored SymbolKind is valid"),
139        start_line: start as usize,
140        end_line: end as usize,
141    })
142}
143
144impl GraphStore for SqliteGraphStore {
145    fn insert(&mut self, path: &str, graph: CodeGraph) {
146        let mut guard = self.conn.lock().expect("connection poisoned");
147        let tx = guard.transaction().expect("begin transaction");
148        tx.execute("DELETE FROM symbols WHERE path = ?1", params![path])
149            .expect("clear symbols for path");
150        tx.execute("DELETE FROM refs WHERE path = ?1", params![path])
151            .expect("clear refs for path");
152        for s in &graph.symbols {
153            tx.execute(
154                "INSERT INTO symbols (path, name, kind, start_line, end_line)
155                 VALUES (?1, ?2, ?3, ?4, ?5)",
156                params![
157                    path,
158                    s.name,
159                    kind_to_text(s),
160                    s.start_line as i64,
161                    s.end_line as i64
162                ],
163            )
164            .expect("insert symbol");
165        }
166        for r in &graph.references {
167            tx.execute(
168                "INSERT INTO refs (path, name, from_fn, line, kind) VALUES (?1, ?2, ?3, ?4, ?5)",
169                params![path, r.name, r.from, r.line as i64, r.kind.as_str()],
170            )
171            .expect("insert reference");
172        }
173        tx.commit().expect("commit transaction");
174    }
175
176    fn symbols_in_file(&self, path: &str) -> Vec<Symbol> {
177        let guard = self.conn.lock().expect("connection poisoned");
178        let mut stmt = guard
179            .prepare("SELECT name, kind, start_line, end_line FROM symbols WHERE path = ?1")
180            .expect("prepare symbols_in_file");
181        let rows = stmt
182            .query_map(params![path], |row| symbol_from_row(row, 0))
183            .expect("query symbols_in_file");
184        rows.collect::<rusqlite::Result<Vec<_>>>()
185            .expect("collect symbols_in_file")
186    }
187
188    fn definitions(&self, name: &str) -> Vec<Located<Symbol>> {
189        let guard = self.conn.lock().expect("connection poisoned");
190        let mut stmt = guard
191            .prepare(
192                "SELECT path, name, kind, start_line, end_line FROM symbols
193                 WHERE name = ?1 ORDER BY path",
194            )
195            .expect("prepare definitions");
196        let rows = stmt
197            .query_map(params![name], |row| {
198                Ok(Located {
199                    path: row.get(0)?,
200                    item: symbol_from_row(row, 1)?,
201                })
202            })
203            .expect("query definitions");
204        rows.collect::<rusqlite::Result<Vec<_>>>()
205            .expect("collect definitions")
206    }
207
208    fn references_to(&self, name: &str) -> Vec<Located<Reference>> {
209        let guard = self.conn.lock().expect("connection poisoned");
210        let mut stmt = guard
211            .prepare(
212                "SELECT path, name, from_fn, line, kind FROM refs
213                 WHERE name = ?1 ORDER BY path, line",
214            )
215            .expect("prepare references_to");
216        let rows = stmt
217            .query_map(params![name], |row| {
218                Ok(Located {
219                    path: row.get(0)?,
220                    item: Reference {
221                        name: row.get(1)?,
222                        from: row.get::<_, Option<String>>(2)?,
223                        line: row.get::<_, i64>(3)? as usize,
224                        kind: RefKind::from_str_or_free(&row.get::<_, String>(4)?),
225                    },
226                })
227            })
228            .expect("query references_to");
229        rows.collect::<rusqlite::Result<Vec<_>>>()
230            .expect("collect references_to")
231    }
232
233    fn callers_of(&self, name: &str) -> Vec<String> {
234        let guard = self.conn.lock().expect("connection poisoned");
235        let mut stmt = guard
236            .prepare(
237                "SELECT DISTINCT from_fn FROM refs
238                 WHERE name = ?1 AND from_fn IS NOT NULL ORDER BY from_fn",
239            )
240            .expect("prepare callers_of");
241        let rows = stmt
242            .query_map(params![name], |row| row.get::<_, String>(0))
243            .expect("query callers_of");
244        rows.collect::<rusqlite::Result<Vec<_>>>()
245            .expect("collect callers_of")
246    }
247
248    fn callees(&self, name: &str) -> Vec<String> {
249        let guard = self.conn.lock().expect("connection poisoned");
250        let mut stmt = guard
251            .prepare("SELECT DISTINCT name FROM refs WHERE from_fn = ?1 ORDER BY name")
252            .expect("prepare callees");
253        let rows = stmt
254            .query_map(params![name], |row| row.get::<_, String>(0))
255            .expect("query callees");
256        rows.collect::<rusqlite::Result<Vec<_>>>()
257            .expect("collect callees")
258    }
259
260    fn all_symbols(&self) -> Vec<Located<Symbol>> {
261        let guard = self.conn.lock().expect("connection poisoned");
262        let mut stmt = guard
263            .prepare("SELECT path, name, kind, start_line, end_line FROM symbols ORDER BY path")
264            .expect("prepare all_symbols");
265        let rows = stmt
266            .query_map([], |row| {
267                Ok(Located {
268                    path: row.get(0)?,
269                    item: symbol_from_row(row, 1)?,
270                })
271            })
272            .expect("query all_symbols");
273        rows.collect::<rusqlite::Result<Vec<_>>>()
274            .expect("collect all_symbols")
275    }
276
277    fn all_references(&self) -> Vec<Located<Reference>> {
278        let guard = self.conn.lock().expect("connection poisoned");
279        let mut stmt = guard
280            .prepare("SELECT path, name, from_fn, line, kind FROM refs ORDER BY path, line")
281            .expect("prepare all_references");
282        let rows = stmt
283            .query_map([], |row| {
284                Ok(Located {
285                    path: row.get(0)?,
286                    item: Reference {
287                        name: row.get(1)?,
288                        from: row.get::<_, Option<String>>(2)?,
289                        line: row.get::<_, i64>(3)? as usize,
290                        kind: RefKind::from_str_or_free(&row.get::<_, String>(4)?),
291                    },
292                })
293            })
294            .expect("query all_references");
295        rows.collect::<rusqlite::Result<Vec<_>>>()
296            .expect("collect all_references")
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    /// Regression for #132: distinct `(repo, view)` pairs that collided under
305    /// the old lossy `_`-collapse must now map to different db files. Under the
306    /// old scheme both `("org/repo","main")` and `("org_repo","main")` produced
307    /// `<root>/org_repo/main.db`; one view's graph would serve/overwrite the
308    /// other. The injective encoder keeps them apart.
309    #[test]
310    fn view_db_path_is_injective_across_colliding_pairs() {
311        let root = Path::new("/graphs");
312        let a = view_db_path(root, "org/repo", "main");
313        let b = view_db_path(root, "org_repo", "main");
314        assert_ne!(a, b, "distinct repos must not collide onto one db file");
315
316        // The `/` is percent-escaped, so `repo` stays a single non-escaping
317        // segment and the filename's only `.` is the `.db` suffix.
318        assert_eq!(a, Path::new("/graphs/org%2Frepo/main.db"));
319        assert_eq!(b, Path::new("/graphs/org_repo/main.db"));
320        assert!(a.extension().is_some_and(|e| e == "db"));
321
322        // Collisions in the view_id component are likewise avoided.
323        let c = view_db_path(root, "org/repo", "v1.0");
324        let d = view_db_path(root, "org/repo", "v1_0");
325        assert_ne!(c, d, "distinct views must not collide onto one db file");
326    }
327}