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 => {} 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 conn.execute_batch(SCHEMA_V1).unwrap();
244 conn.pragma_update(None, "user_version", 1).unwrap();
245 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}