Skip to main content

dk_engine/graph/
symbols.rs

1use std::path::PathBuf;
2
3use dk_core::{RepoId, Span, Symbol, SymbolId, SymbolKind, Visibility};
4use sqlx::postgres::PgPool;
5use uuid::Uuid;
6
7/// Intermediate row type for mapping between database rows and `Symbol`.
8#[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/// PostgreSQL-backed CRUD store for the symbol table.
56#[derive(Clone)]
57pub struct SymbolStore {
58    pool: PgPool,
59}
60
61impl SymbolStore {
62    /// Create a new `SymbolStore` backed by the given connection pool.
63    pub fn new(pool: PgPool) -> Self {
64        Self { pool }
65    }
66
67    /// Insert or update a symbol.
68    ///
69    /// Uses `ON CONFLICT (repo_id, qualified_name) DO UPDATE` so that
70    /// repeated ingestion of the same file is idempotent.
71    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    /// Search symbols by name or qualified_name using ILIKE.
129    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    /// Find all symbols of a given kind in a repository.
154    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    /// Find all symbols in a given file.
179    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    /// Get a single symbol by its ID.
203    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    /// Fetch multiple symbols by their IDs in a single batch query.
221    ///
222    /// Returns symbols in arbitrary order. Symbols that do not exist are
223    /// silently omitted.
224    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    /// Delete all symbols belonging to a file. Returns the number of rows deleted.
246    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    /// Delete all symbols belonging to a repository. Returns the number of rows deleted.
263    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    /// Count symbols in a repository.
273    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}