1use std::path::PathBuf;
2
3use dk_core::{RepoId, Span, Symbol, SymbolId, SymbolKind, Visibility};
4use sqlx::postgres::PgPool;
5use uuid::Uuid;
6
7#[derive(sqlx::FromRow)]
9#[allow(dead_code)]
10struct SymbolRow {
11 id: Uuid,
12 repo_id: Uuid,
13 name: String,
14 qualified_name: String,
15 kind: String,
16 visibility: String,
17 file_path: String,
18 start_byte: i32,
19 end_byte: i32,
20 signature: Option<String>,
21 doc_comment: Option<String>,
22 parent_id: Option<Uuid>,
23 last_modified_by: Option<String>,
24 last_modified_intent: Option<String>,
25}
26
27impl SymbolRow {
28 fn into_symbol(self) -> Symbol {
29 Symbol {
30 id: self.id,
31 name: self.name,
32 qualified_name: self.qualified_name,
33 kind: self.kind.parse::<SymbolKind>().unwrap_or_else(|e| {
34 tracing::warn!("{e}, defaulting to Variable");
35 SymbolKind::Variable
36 }),
37 visibility: self.visibility.parse::<Visibility>().unwrap_or_else(|e| {
38 tracing::warn!("{e}, defaulting to Private");
39 Visibility::Private
40 }),
41 file_path: PathBuf::from(self.file_path),
42 span: Span {
43 start_byte: self.start_byte as u32,
44 end_byte: self.end_byte as u32,
45 },
46 signature: self.signature,
47 doc_comment: self.doc_comment,
48 parent: self.parent_id,
49 last_modified_by: self.last_modified_by,
50 last_modified_intent: self.last_modified_intent,
51 }
52 }
53}
54
55#[derive(Clone)]
57pub struct SymbolStore {
58 pool: PgPool,
59}
60
61impl SymbolStore {
62 pub fn new(pool: PgPool) -> Self {
64 Self { pool }
65 }
66
67 pub async fn upsert_symbol(
72 &self,
73 repo_id: RepoId,
74 sym: &Symbol,
75 ) -> dk_core::Result<()> {
76 let kind_str = sym.kind.to_string();
77 let vis_str = sym.visibility.to_string();
78 let file_path_str = sym.file_path.to_string_lossy().to_string();
79
80 sqlx::query(
81 r#"
82 INSERT INTO symbols (
83 id, repo_id, name, qualified_name, kind, visibility,
84 file_path, start_byte, end_byte, signature, doc_comment,
85 parent_id, last_modified_by, last_modified_intent
86 )
87 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
88 ON CONFLICT (repo_id, qualified_name) DO UPDATE SET
89 -- Safe: migration 014 adds ON UPDATE CASCADE to all FKs referencing
90 -- symbols(id) (call_edges, type_info, symbol_dependencies, parent_id).
91 -- Note: changeset_symbols.symbol_id has NO FK to symbols(id) by design
92 -- (migration 009) — it retains stale UUIDs after PK update. This is OK
93 -- because get_affected_symbols only uses qualified_name, not symbol_id.
94 id = EXCLUDED.id,
95 name = EXCLUDED.name,
96 kind = EXCLUDED.kind,
97 visibility = EXCLUDED.visibility,
98 file_path = EXCLUDED.file_path,
99 start_byte = EXCLUDED.start_byte,
100 end_byte = EXCLUDED.end_byte,
101 signature = EXCLUDED.signature,
102 doc_comment = EXCLUDED.doc_comment,
103 parent_id = EXCLUDED.parent_id,
104 last_modified_by = EXCLUDED.last_modified_by,
105 last_modified_intent = EXCLUDED.last_modified_intent
106 "#,
107 )
108 .bind(sym.id)
109 .bind(repo_id)
110 .bind(&sym.name)
111 .bind(&sym.qualified_name)
112 .bind(&kind_str)
113 .bind(&vis_str)
114 .bind(&file_path_str)
115 .bind(sym.span.start_byte as i32)
116 .bind(sym.span.end_byte as i32)
117 .bind(&sym.signature)
118 .bind(&sym.doc_comment)
119 .bind(sym.parent)
120 .bind(&sym.last_modified_by)
121 .bind(&sym.last_modified_intent)
122 .execute(&self.pool)
123 .await?;
124
125 Ok(())
126 }
127
128 pub async fn find_symbols(
130 &self,
131 repo_id: RepoId,
132 query: &str,
133 ) -> dk_core::Result<Vec<Symbol>> {
134 let pattern = format!("%{query}%");
135 let rows = sqlx::query_as::<_, SymbolRow>(
136 r#"
137 SELECT id, repo_id, name, qualified_name, kind, visibility,
138 file_path, start_byte, end_byte, signature, doc_comment,
139 parent_id, last_modified_by, last_modified_intent
140 FROM symbols
141 WHERE repo_id = $1 AND (name ILIKE $2 OR qualified_name ILIKE $2)
142 ORDER BY qualified_name
143 "#,
144 )
145 .bind(repo_id)
146 .bind(&pattern)
147 .fetch_all(&self.pool)
148 .await?;
149
150 Ok(rows.into_iter().map(SymbolRow::into_symbol).collect())
151 }
152
153 pub async fn find_by_kind(
155 &self,
156 repo_id: RepoId,
157 kind: &SymbolKind,
158 ) -> dk_core::Result<Vec<Symbol>> {
159 let kind_str = kind.to_string();
160 let rows = sqlx::query_as::<_, SymbolRow>(
161 r#"
162 SELECT id, repo_id, name, qualified_name, kind, visibility,
163 file_path, start_byte, end_byte, signature, doc_comment,
164 parent_id, last_modified_by, last_modified_intent
165 FROM symbols
166 WHERE repo_id = $1 AND kind = $2
167 ORDER BY qualified_name
168 "#,
169 )
170 .bind(repo_id)
171 .bind(&kind_str)
172 .fetch_all(&self.pool)
173 .await?;
174
175 Ok(rows.into_iter().map(SymbolRow::into_symbol).collect())
176 }
177
178 pub async fn find_by_file(
180 &self,
181 repo_id: RepoId,
182 file_path: &str,
183 ) -> dk_core::Result<Vec<Symbol>> {
184 let rows = sqlx::query_as::<_, SymbolRow>(
185 r#"
186 SELECT id, repo_id, name, qualified_name, kind, visibility,
187 file_path, start_byte, end_byte, signature, doc_comment,
188 parent_id, last_modified_by, last_modified_intent
189 FROM symbols
190 WHERE repo_id = $1 AND file_path = $2
191 ORDER BY start_byte
192 "#,
193 )
194 .bind(repo_id)
195 .bind(file_path)
196 .fetch_all(&self.pool)
197 .await?;
198
199 Ok(rows.into_iter().map(SymbolRow::into_symbol).collect())
200 }
201
202 pub async fn get_by_id(&self, id: SymbolId) -> dk_core::Result<Option<Symbol>> {
204 let row = sqlx::query_as::<_, SymbolRow>(
205 r#"
206 SELECT id, repo_id, name, qualified_name, kind, visibility,
207 file_path, start_byte, end_byte, signature, doc_comment,
208 parent_id, last_modified_by, last_modified_intent
209 FROM symbols
210 WHERE id = $1
211 "#,
212 )
213 .bind(id)
214 .fetch_optional(&self.pool)
215 .await?;
216
217 Ok(row.map(SymbolRow::into_symbol))
218 }
219
220 pub async fn get_by_ids(&self, ids: &[SymbolId]) -> dk_core::Result<Vec<Symbol>> {
225 if ids.is_empty() {
226 return Ok(Vec::new());
227 }
228
229 let rows = sqlx::query_as::<_, SymbolRow>(
230 r#"
231 SELECT id, repo_id, name, qualified_name, kind, visibility,
232 file_path, start_byte, end_byte, signature, doc_comment,
233 parent_id, last_modified_by, last_modified_intent
234 FROM symbols
235 WHERE id = ANY($1)
236 "#,
237 )
238 .bind(ids)
239 .fetch_all(&self.pool)
240 .await?;
241
242 Ok(rows.into_iter().map(SymbolRow::into_symbol).collect())
243 }
244
245 pub async fn delete_by_file(
247 &self,
248 repo_id: RepoId,
249 file_path: &str,
250 ) -> dk_core::Result<u64> {
251 let result = sqlx::query(
252 "DELETE FROM symbols WHERE repo_id = $1 AND file_path = $2",
253 )
254 .bind(repo_id)
255 .bind(file_path)
256 .execute(&self.pool)
257 .await?;
258
259 Ok(result.rows_affected())
260 }
261
262 pub async fn delete_by_repo(&self, repo_id: RepoId) -> dk_core::Result<u64> {
264 let result = sqlx::query("DELETE FROM symbols WHERE repo_id = $1")
265 .bind(repo_id)
266 .execute(&self.pool)
267 .await?;
268
269 Ok(result.rows_affected())
270 }
271
272 pub async fn count(&self, repo_id: RepoId) -> dk_core::Result<i64> {
274 let (count,): (i64,) =
275 sqlx::query_as("SELECT COUNT(*) FROM symbols WHERE repo_id = $1")
276 .bind(repo_id)
277 .fetch_one(&self.pool)
278 .await?;
279
280 Ok(count)
281 }
282}