Skip to main content

the_code_graph_storage/
schema.rs

1use domain::error::{CodeGraphError, Result};
2use rusqlite::Connection;
3
4#[cfg(test)]
5pub(crate) const SCHEMA_V1: &str = "
6CREATE TABLE metadata (
7    key TEXT PRIMARY KEY,
8    value TEXT NOT NULL
9);
10
11CREATE TABLE files (
12    path TEXT PRIMARY KEY,
13    language TEXT NOT NULL,
14    hash TEXT NOT NULL,
15    updated_at INTEGER NOT NULL
16);
17
18CREATE TABLE non_parsed_files (
19    path TEXT PRIMARY KEY,
20    kind TEXT NOT NULL,
21    hash TEXT NOT NULL,
22    updated_at INTEGER NOT NULL
23);
24
25CREATE TABLE symbols (
26    qualified_name TEXT PRIMARY KEY,
27    name TEXT NOT NULL,
28    kind TEXT NOT NULL,
29    file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
30    line_start INTEGER NOT NULL,
31    line_end INTEGER NOT NULL,
32    col_start INTEGER NOT NULL,
33    col_end INTEGER NOT NULL,
34    visibility TEXT NOT NULL DEFAULT 'private',
35    is_exported INTEGER NOT NULL DEFAULT 0,
36    is_async INTEGER NOT NULL DEFAULT 0,
37    is_test INTEGER NOT NULL DEFAULT 0,
38    decorators TEXT,
39    signature TEXT,
40    updated_at INTEGER NOT NULL
41);
42
43CREATE TABLE edges (
44    id INTEGER PRIMARY KEY AUTOINCREMENT,
45    kind TEXT NOT NULL,
46    source_qualified TEXT NOT NULL,
47    target_qualified TEXT NOT NULL,
48    metadata TEXT,
49    UNIQUE(kind, source_qualified, target_qualified)
50);
51
52CREATE VIRTUAL TABLE symbols_fts USING fts5(
53    name, qualified_name, file_path, signature,
54    content='symbols', content_rowid='rowid'
55);
56
57CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
58    INSERT INTO symbols_fts(rowid, name, qualified_name, file_path, signature)
59    VALUES (new.rowid, new.name, new.qualified_name, new.file_path, new.signature);
60END;
61
62CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
63    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, file_path, signature)
64    VALUES ('delete', old.rowid, old.name, old.qualified_name, old.file_path, old.signature);
65END;
66
67CREATE TRIGGER symbols_au AFTER UPDATE ON symbols BEGIN
68    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, file_path, signature)
69    VALUES ('delete', old.rowid, old.name, old.qualified_name, old.file_path, old.signature);
70    INSERT INTO symbols_fts(rowid, name, qualified_name, file_path, signature)
71    VALUES (new.rowid, new.name, new.qualified_name, new.file_path, new.signature);
72END;
73
74CREATE INDEX idx_symbols_file ON symbols(file_path);
75CREATE INDEX idx_symbols_kind ON symbols(kind);
76CREATE INDEX idx_symbols_name ON symbols(name);
77CREATE INDEX idx_edges_source ON edges(source_qualified);
78CREATE INDEX idx_edges_target ON edges(target_qualified);
79CREATE INDEX idx_edges_kind ON edges(kind);
80";
81
82pub(crate) const MIGRATION_V1_TO_V2: &str = "
83CREATE TABLE embeddings (
84    qualified_name TEXT PRIMARY KEY REFERENCES symbols(qualified_name) ON DELETE CASCADE,
85    vector BLOB NOT NULL,
86    text_hash TEXT NOT NULL,
87    provider TEXT NOT NULL,
88    created_at TEXT NOT NULL
89);
90CREATE INDEX idx_embeddings_provider ON embeddings(provider);
91";
92
93pub(crate) const SCHEMA_V2: &str = "
94CREATE TABLE metadata (
95    key TEXT PRIMARY KEY,
96    value TEXT NOT NULL
97);
98
99CREATE TABLE files (
100    path TEXT PRIMARY KEY,
101    language TEXT NOT NULL,
102    hash TEXT NOT NULL,
103    updated_at INTEGER NOT NULL
104);
105
106CREATE TABLE non_parsed_files (
107    path TEXT PRIMARY KEY,
108    kind TEXT NOT NULL,
109    hash TEXT NOT NULL,
110    updated_at INTEGER NOT NULL
111);
112
113CREATE TABLE symbols (
114    qualified_name TEXT PRIMARY KEY,
115    name TEXT NOT NULL,
116    kind TEXT NOT NULL,
117    file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
118    line_start INTEGER NOT NULL,
119    line_end INTEGER NOT NULL,
120    col_start INTEGER NOT NULL,
121    col_end INTEGER NOT NULL,
122    visibility TEXT NOT NULL DEFAULT 'private',
123    is_exported INTEGER NOT NULL DEFAULT 0,
124    is_async INTEGER NOT NULL DEFAULT 0,
125    is_test INTEGER NOT NULL DEFAULT 0,
126    decorators TEXT,
127    signature TEXT,
128    updated_at INTEGER NOT NULL
129);
130
131CREATE TABLE edges (
132    id INTEGER PRIMARY KEY AUTOINCREMENT,
133    kind TEXT NOT NULL,
134    source_qualified TEXT NOT NULL,
135    target_qualified TEXT NOT NULL,
136    metadata TEXT,
137    UNIQUE(kind, source_qualified, target_qualified)
138);
139
140CREATE VIRTUAL TABLE symbols_fts USING fts5(
141    name, qualified_name, file_path, signature,
142    content='symbols', content_rowid='rowid'
143);
144
145CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
146    INSERT INTO symbols_fts(rowid, name, qualified_name, file_path, signature)
147    VALUES (new.rowid, new.name, new.qualified_name, new.file_path, new.signature);
148END;
149
150CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
151    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, file_path, signature)
152    VALUES ('delete', old.rowid, old.name, old.qualified_name, old.file_path, old.signature);
153END;
154
155CREATE TRIGGER symbols_au AFTER UPDATE ON symbols BEGIN
156    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, file_path, signature)
157    VALUES ('delete', old.rowid, old.name, old.qualified_name, old.file_path, old.signature);
158    INSERT INTO symbols_fts(rowid, name, qualified_name, file_path, signature)
159    VALUES (new.rowid, new.name, new.qualified_name, new.file_path, new.signature);
160END;
161
162CREATE INDEX idx_symbols_file ON symbols(file_path);
163CREATE INDEX idx_symbols_kind ON symbols(kind);
164CREATE INDEX idx_symbols_name ON symbols(name);
165CREATE INDEX idx_edges_source ON edges(source_qualified);
166CREATE INDEX idx_edges_target ON edges(target_qualified);
167CREATE INDEX idx_edges_kind ON edges(kind);
168
169CREATE TABLE embeddings (
170    qualified_name TEXT PRIMARY KEY REFERENCES symbols(qualified_name) ON DELETE CASCADE,
171    vector BLOB NOT NULL,
172    text_hash TEXT NOT NULL,
173    provider TEXT NOT NULL,
174    created_at TEXT NOT NULL
175);
176CREATE INDEX idx_embeddings_provider ON embeddings(provider);
177";
178
179pub(crate) fn ensure_schema(conn: &Connection) -> Result<()> {
180    let version: i32 = conn
181        .query_row("PRAGMA user_version", [], |r| r.get(0))
182        .map_err(map_rusqlite_error)?;
183    match version {
184        0 => {
185            conn.execute_batch(SCHEMA_V2).map_err(map_rusqlite_error)?;
186            conn.pragma_update(None, "user_version", 2)
187                .map_err(map_rusqlite_error)?;
188        }
189        1 => {
190            conn.execute_batch(MIGRATION_V1_TO_V2)
191                .map_err(map_rusqlite_error)?;
192            conn.pragma_update(None, "user_version", 2)
193                .map_err(map_rusqlite_error)?;
194        }
195        2 => {} // current
196        v => {
197            return Err(CodeGraphError::Storage(format!(
198                "unsupported schema version: {v}"
199            )));
200        }
201    }
202    Ok(())
203}
204
205fn map_rusqlite_error(e: rusqlite::Error) -> CodeGraphError {
206    CodeGraphError::Storage(e.to_string())
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use rusqlite::Connection;
213
214    fn has_table(conn: &Connection, table: &str) -> bool {
215        let count: i64 = conn
216            .query_row(
217                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
218                rusqlite::params![table],
219                |r| r.get(0),
220            )
221            .unwrap_or(0);
222        count > 0
223    }
224
225    #[test]
226    fn schema_v0_to_v2_creates_embeddings_table() {
227        let conn = Connection::open_in_memory().unwrap();
228        ensure_schema(&conn).unwrap();
229        assert!(
230            has_table(&conn, "embeddings"),
231            "embeddings table must exist after v0→v2"
232        );
233        let version: i32 = conn
234            .query_row("PRAGMA user_version", [], |r| r.get(0))
235            .unwrap();
236        assert_eq!(version, 2);
237    }
238
239    #[test]
240    fn schema_v1_to_v2_migration_creates_embeddings_table() {
241        let conn = Connection::open_in_memory().unwrap();
242        // Bootstrap a v1 schema manually
243        conn.execute_batch(SCHEMA_V1).unwrap();
244        conn.pragma_update(None, "user_version", 1).unwrap();
245        // Now run ensure_schema which should migrate v1→v2
246        ensure_schema(&conn).unwrap();
247        assert!(
248            has_table(&conn, "embeddings"),
249            "embeddings table must exist after v1→v2"
250        );
251        let version: i32 = conn
252            .query_row("PRAGMA user_version", [], |r| r.get(0))
253            .unwrap();
254        assert_eq!(version, 2);
255    }
256}