Skip to main content

tessera_codegraph/
db.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{bail, Result};
5use rusqlite::{params, Connection, OptionalExtension};
6
7use crate::types::{
8    IndexedImport, IndexedReference, IndexedSymbol, Language, ReferenceRecord, SymbolRecord,
9};
10
11pub const SCHEMA_VERSION: i64 = 3;
12
13pub fn open(path: &Path) -> Result<Connection> {
14    if let Some(parent) = path.parent() {
15        if !parent.as_os_str().is_empty() {
16            fs::create_dir_all(parent)?;
17        }
18    }
19    let conn = Connection::open(path)?;
20    conn.pragma_update(None, "journal_mode", "WAL")?;
21    conn.pragma_update(None, "foreign_keys", "ON")?;
22    conn.pragma_update(None, "synchronous", "NORMAL")?;
23    migrate(&conn)?;
24    Ok(conn)
25}
26
27pub fn open_existing(path: &Path) -> Result<Connection> {
28    if !path.exists() {
29        bail!(
30            "Tessera database not found at {}. Run `tessera index . --db {}` first.",
31            path.display(),
32            path.display()
33        );
34    }
35    open(path)
36}
37
38pub fn reset(conn: &Connection) -> Result<()> {
39    // The FTS5 virtual table is configured with external content (content='symbols')
40    // and is kept in sync via triggers. Deleting `symbols` here causes the
41    // AFTER DELETE trigger to fire for every row, which is correct but slow on
42    // large tables; for v0.2's repo sizes the cost is irrelevant.
43    conn.execute_batch(
44        "
45        DELETE FROM imports;
46        DELETE FROM edges;
47        DELETE FROM refs;
48        DELETE FROM symbols;
49        DELETE FROM files;
50        ",
51    )?;
52    // Ask FTS5 to drop any orphaned tombstones.
53    let _ = conn.execute_batch("INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all');");
54    Ok(())
55}
56
57fn migrate(conn: &Connection) -> Result<()> {
58    conn.execute_batch(
59        "
60        CREATE TABLE IF NOT EXISTS files (
61            id INTEGER PRIMARY KEY,
62            path TEXT NOT NULL UNIQUE,
63            language TEXT NOT NULL,
64            sha256 TEXT NOT NULL,
65            loc INTEGER NOT NULL,
66            indexed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
67        );
68
69        CREATE TABLE IF NOT EXISTS meta (
70            key TEXT PRIMARY KEY,
71            value TEXT NOT NULL
72        );
73
74        CREATE TABLE IF NOT EXISTS meta_blob (
75            key TEXT PRIMARY KEY,
76            value BLOB NOT NULL
77        );
78
79        CREATE TABLE IF NOT EXISTS symbols (
80            id INTEGER PRIMARY KEY,
81            name TEXT NOT NULL,
82            qualified_name TEXT NOT NULL,
83            kind TEXT NOT NULL,
84            file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
85            start_line INTEGER NOT NULL,
86            end_line INTEGER NOT NULL,
87            signature TEXT NOT NULL,
88            exported INTEGER NOT NULL DEFAULT 0
89        );
90
91        CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
92        CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);
93        CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
94
95        CREATE TABLE IF NOT EXISTS refs (
96            id INTEGER PRIMARY KEY,
97            symbol_name TEXT NOT NULL,
98            from_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
99            file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
100            line INTEGER NOT NULL,
101            column INTEGER NOT NULL,
102            context TEXT NOT NULL,
103            kind TEXT NOT NULL
104        );
105
106        CREATE INDEX IF NOT EXISTS idx_refs_symbol ON refs(symbol_name);
107        CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_symbol_id);
108        CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);
109
110        CREATE TABLE IF NOT EXISTS edges (
111            id INTEGER PRIMARY KEY,
112            from_symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
113            to_symbol_name TEXT NOT NULL,
114            kind TEXT NOT NULL,
115            weight REAL NOT NULL DEFAULT 1.0
116        );
117
118        CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_symbol_name);
119        CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_symbol_id);
120
121        CREATE TABLE IF NOT EXISTS imports (
122            id INTEGER PRIMARY KEY,
123            file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
124            source TEXT NOT NULL,
125            line INTEGER NOT NULL,
126            kind TEXT NOT NULL
127        );
128
129        CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file_id);
130        CREATE INDEX IF NOT EXISTS idx_imports_source ON imports(source);
131        ",
132    )?;
133
134    // FTS5 trigram virtual table. rusqlite's bundled SQLite ships FTS5 and the
135    // trigram tokenizer. The triggers below keep it in sync with `symbols`.
136    let fts_setup = "
137        CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
138            name, qualified_name,
139            content='symbols', content_rowid='id',
140            tokenize='trigram'
141        );
142
143        CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
144            INSERT INTO symbols_fts(rowid, name, qualified_name)
145            VALUES (new.id, new.name, new.qualified_name);
146        END;
147
148        CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
149            INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name)
150            VALUES('delete', old.id, old.name, old.qualified_name);
151        END;
152        ";
153    // FTS5 may not be available in unusual builds; if it fails, fall back silently.
154    let _ = conn.execute_batch(fts_setup);
155
156    set_meta(conn, "schema_version", &SCHEMA_VERSION.to_string())?;
157    Ok(())
158}
159
160pub fn set_meta(conn: &Connection, key: &str, value: &str) -> Result<()> {
161    conn.execute(
162        "
163        INSERT INTO meta(key, value) VALUES (?1, ?2)
164        ON CONFLICT(key) DO UPDATE SET value = excluded.value
165        ",
166        params![key, value],
167    )?;
168    Ok(())
169}
170
171pub fn get_meta(conn: &Connection, key: &str) -> Result<Option<String>> {
172    conn.query_row(
173        "SELECT value FROM meta WHERE key = ?1",
174        params![key],
175        |row| row.get(0),
176    )
177    .optional()
178    .map_err(Into::into)
179}
180
181pub fn set_meta_blob(conn: &Connection, key: &str, value: &[u8]) -> Result<()> {
182    conn.execute(
183        "
184        INSERT INTO meta_blob(key, value) VALUES (?1, ?2)
185        ON CONFLICT(key) DO UPDATE SET value = excluded.value
186        ",
187        params![key, value],
188    )?;
189    Ok(())
190}
191
192pub fn get_meta_blob(conn: &Connection, key: &str) -> Result<Option<Vec<u8>>> {
193    conn.query_row(
194        "SELECT value FROM meta_blob WHERE key = ?1",
195        params![key],
196        |row| row.get::<_, Vec<u8>>(0),
197    )
198    .optional()
199    .map_err(Into::into)
200}
201
202pub fn file_sha(conn: &Connection, path: &str) -> Result<Option<(i64, String)>> {
203    conn.query_row(
204        "SELECT id, sha256 FROM files WHERE path = ?1",
205        params![path],
206        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
207    )
208    .optional()
209    .map_err(Into::into)
210}
211
212pub fn delete_file_cascade(conn: &Connection, file_id: i64) -> Result<()> {
213    conn.execute("DELETE FROM files WHERE id = ?1", params![file_id])?;
214    Ok(())
215}
216
217pub fn delete_files_not_in(conn: &Connection, retained_ids: &[i64]) -> Result<usize> {
218    if retained_ids.is_empty() {
219        let removed = conn.execute("DELETE FROM files", [])?;
220        return Ok(removed);
221    }
222    let placeholders = retained_ids
223        .iter()
224        .map(|_| "?")
225        .collect::<Vec<_>>()
226        .join(",");
227    let sql = format!("DELETE FROM files WHERE id NOT IN ({placeholders})");
228    let params_iter: Vec<&dyn rusqlite::ToSql> = retained_ids
229        .iter()
230        .map(|id| id as &dyn rusqlite::ToSql)
231        .collect();
232    let removed = conn.execute(&sql, params_iter.as_slice())?;
233    Ok(removed)
234}
235
236pub fn insert_file(
237    conn: &Connection,
238    path: &str,
239    language: Language,
240    sha256: &str,
241    loc: usize,
242) -> Result<i64> {
243    conn.execute(
244        "INSERT INTO files(path, language, sha256, loc) VALUES (?1, ?2, ?3, ?4)",
245        params![path, language.to_string(), sha256, loc as i64],
246    )?;
247    Ok(conn.last_insert_rowid())
248}
249
250pub fn insert_symbols(
251    conn: &Connection,
252    file_id: i64,
253    symbols: &[IndexedSymbol],
254) -> Result<Vec<i64>> {
255    let mut ids = Vec::with_capacity(symbols.len());
256    for symbol in symbols {
257        conn.execute(
258            "
259            INSERT INTO symbols(
260                name, qualified_name, kind, file_id, start_line, end_line, signature, exported
261            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
262            ",
263            params![
264                symbol.name,
265                symbol.qualified_name,
266                symbol.kind,
267                file_id,
268                symbol.start_line as i64,
269                symbol.end_line as i64,
270                symbol.signature,
271                i64::from(symbol.exported)
272            ],
273        )?;
274        ids.push(conn.last_insert_rowid());
275    }
276    Ok(ids)
277}
278
279pub fn find_symbol_id(
280    conn: &Connection,
281    file_id: i64,
282    qualified_name: &str,
283) -> Result<Option<i64>> {
284    conn.query_row(
285        "SELECT id FROM symbols WHERE file_id = ?1 AND qualified_name = ?2",
286        params![file_id, qualified_name],
287        |row| row.get(0),
288    )
289    .optional()
290    .map_err(Into::into)
291}
292
293pub fn insert_imports(conn: &Connection, file_id: i64, imports: &[IndexedImport]) -> Result<usize> {
294    for imp in imports {
295        conn.execute(
296            "INSERT INTO imports(file_id, source, line, kind) VALUES (?1, ?2, ?3, ?4)",
297            params![file_id, imp.source, imp.line as i64, imp.kind],
298        )?;
299    }
300    Ok(imports.len())
301}
302
303pub fn insert_references(
304    conn: &Connection,
305    file_id: i64,
306    refs: &[IndexedReference],
307) -> Result<usize> {
308    let mut count = 0;
309    for reference in refs {
310        let from_symbol_id = reference
311            .from_qualified_name
312            .as_deref()
313            .map(|name| find_symbol_id(conn, file_id, name))
314            .transpose()?
315            .flatten();
316        conn.execute(
317            "
318            INSERT INTO refs(
319                symbol_name, from_symbol_id, file_id, line, column, context, kind
320            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
321            ",
322            params![
323                reference.symbol_name,
324                from_symbol_id,
325                file_id,
326                reference.line as i64,
327                reference.column as i64,
328                reference.context,
329                reference.kind
330            ],
331        )?;
332        if let Some(from_symbol_id) = from_symbol_id {
333            conn.execute(
334                "
335                INSERT INTO edges(from_symbol_id, to_symbol_name, kind, weight)
336                VALUES (?1, ?2, ?3, 1.0)
337                ",
338                params![from_symbol_id, reference.symbol_name, reference.kind],
339            )?;
340        }
341        count += 1;
342    }
343    Ok(count)
344}
345
346pub fn resolve_symbol(conn: &Connection, symbol: &str) -> Result<Option<SymbolRecord>> {
347    conn.query_row(
348        "
349        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
350               s.start_line, s.end_line, s.signature, s.exported
351        FROM symbols s
352        JOIN files f ON f.id = s.file_id
353        WHERE s.qualified_name = ?1 OR s.name = ?1 OR s.qualified_name LIKE ?2
354        ORDER BY
355            CASE
356                WHEN s.qualified_name = ?1 THEN 0
357                WHEN s.name = ?1 THEN 1
358                ELSE 2
359            END,
360            length(s.qualified_name)
361        LIMIT 1
362        ",
363        params![symbol, format!("%.{}", symbol)],
364        map_symbol,
365    )
366    .optional()
367    .map_err(Into::into)
368}
369
370pub fn symbol_fanout(conn: &Connection, symbol_id: i64) -> Result<usize> {
371    conn.query_row(
372        "SELECT COUNT(DISTINCT to_symbol_name) FROM edges WHERE from_symbol_id = ?1",
373        params![symbol_id],
374        |row| row.get::<_, i64>(0),
375    )
376    .map(|count| count as usize)
377    .map_err(Into::into)
378}
379
380pub fn symbol_callers_count(conn: &Connection, symbol_name: &str) -> Result<usize> {
381    conn.query_row(
382        "SELECT COUNT(DISTINCT from_symbol_id) FROM edges WHERE to_symbol_name = ?1",
383        params![symbol_name],
384        |row| row.get::<_, i64>(0),
385    )
386    .map(|count| count as usize)
387    .map_err(Into::into)
388}
389
390pub fn count_imports(conn: &Connection) -> Result<usize> {
391    conn.query_row("SELECT COUNT(*) FROM imports", [], |row| {
392        row.get::<_, i64>(0)
393    })
394    .map(|c| c as usize)
395    .map_err(Into::into)
396}
397
398pub fn count_files(conn: &Connection) -> Result<usize> {
399    conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0))
400        .map(|c| c as usize)
401        .map_err(Into::into)
402}
403
404pub fn count_symbols(conn: &Connection) -> Result<usize> {
405    conn.query_row("SELECT COUNT(*) FROM symbols", [], |row| {
406        row.get::<_, i64>(0)
407    })
408    .map(|c| c as usize)
409    .map_err(Into::into)
410}
411
412pub fn count_refs(conn: &Connection) -> Result<usize> {
413    conn.query_row("SELECT COUNT(*) FROM refs", [], |row| row.get::<_, i64>(0))
414        .map(|c| c as usize)
415        .map_err(Into::into)
416}
417
418pub fn count_edges(conn: &Connection) -> Result<usize> {
419    conn.query_row("SELECT COUNT(*) FROM edges", [], |row| row.get::<_, i64>(0))
420        .map(|c| c as usize)
421        .map_err(Into::into)
422}
423
424pub fn map_symbol(row: &rusqlite::Row<'_>) -> rusqlite::Result<SymbolRecord> {
425    Ok(SymbolRecord {
426        id: row.get(0)?,
427        name: row.get(1)?,
428        qualified_name: row.get(2)?,
429        kind: row.get(3)?,
430        file_id: row.get(4)?,
431        path: row.get(5)?,
432        language: row.get(6)?,
433        start_line: row.get::<_, i64>(7)? as usize,
434        end_line: row.get::<_, i64>(8)? as usize,
435        signature: row.get(9)?,
436        exported: row.get::<_, i64>(10)? != 0,
437    })
438}
439
440pub fn map_reference(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReferenceRecord> {
441    Ok(ReferenceRecord {
442        id: row.get(0)?,
443        symbol_name: row.get(1)?,
444        from_symbol_id: row.get(2)?,
445        from_symbol: row.get(3)?,
446        path: row.get(4)?,
447        line: row.get::<_, i64>(5)? as usize,
448        column: row.get::<_, i64>(6)? as usize,
449        context: row.get(7)?,
450        kind: row.get(8)?,
451    })
452}