Skip to main content

gobby_code/db/
queries.rs

1use anyhow::bail;
2use postgres::GenericClient;
3
4use crate::models::{CallRelation, CallTargetKind, ImportRelation, Symbol};
5use crate::utils::i64_to_usize;
6
7#[derive(Debug, Clone)]
8pub struct GraphFileFacts {
9    pub file_path: String,
10    pub imports: Vec<ImportRelation>,
11    pub definitions: Vec<Symbol>,
12    pub calls: Vec<CallRelation>,
13}
14
15pub fn list_indexed_file_paths(
16    conn: &mut impl GenericClient,
17    project_id: &str,
18) -> anyhow::Result<Vec<String>> {
19    let rows = conn.query(
20        "SELECT file_path FROM code_indexed_files WHERE project_id = $1 ORDER BY file_path",
21        &[&project_id],
22    )?;
23    rows.into_iter()
24        .map(|row| row.try_get("file_path").map_err(Into::into))
25        .collect()
26}
27
28pub fn indexed_project_exists(
29    conn: &mut impl GenericClient,
30    project_id: &str,
31) -> anyhow::Result<bool> {
32    Ok(conn
33        .query_opt(
34            "SELECT 1 FROM code_indexed_projects WHERE id = $1",
35            &[&project_id],
36        )?
37        .is_some())
38}
39
40pub fn read_graph_file_facts(
41    conn: &mut impl GenericClient,
42    project_id: &str,
43    file_path: &str,
44) -> anyhow::Result<GraphFileFacts> {
45    let imports = read_imports_for_file(conn, project_id, file_path)?;
46    let definitions = read_symbols_for_file(conn, project_id, file_path)?;
47    let calls = read_calls_for_file(conn, project_id, file_path)?;
48
49    Ok(GraphFileFacts {
50        file_path: file_path.to_string(),
51        imports,
52        definitions,
53        calls,
54    })
55}
56
57pub fn indexed_file_exists(
58    conn: &mut impl GenericClient,
59    project_id: &str,
60    file_path: &str,
61) -> anyhow::Result<bool> {
62    Ok(conn
63        .query_opt(
64            "SELECT 1 FROM code_indexed_files
65             WHERE project_id = $1 AND file_path = $2",
66            &[&project_id, &file_path],
67        )?
68        .is_some())
69}
70
71pub fn mark_graph_sync_attempted(
72    conn: &mut impl GenericClient,
73    project_id: &str,
74    file_path: &str,
75) -> anyhow::Result<bool> {
76    let updated = conn.execute(
77        "UPDATE code_indexed_files
78         SET graph_synced = false, graph_sync_attempted_at = NOW()
79         WHERE project_id = $1 AND file_path = $2",
80        &[&project_id, &file_path],
81    )?;
82    Ok(updated > 0)
83}
84
85pub fn mark_graph_synced(
86    conn: &mut impl GenericClient,
87    project_id: &str,
88    file_path: &str,
89) -> anyhow::Result<bool> {
90    let updated = conn.execute(
91        "UPDATE code_indexed_files
92         SET graph_synced = true, graph_sync_attempted_at = NOW()
93         WHERE project_id = $1 AND file_path = $2",
94        &[&project_id, &file_path],
95    )?;
96    Ok(updated > 0)
97}
98
99pub fn reset_graph_sync_for_project(
100    conn: &mut impl GenericClient,
101    project_id: &str,
102) -> anyhow::Result<u64> {
103    Ok(conn.execute(
104        "UPDATE code_indexed_files
105         SET graph_synced = false, graph_sync_attempted_at = NULL
106         WHERE project_id = $1",
107        &[&project_id],
108    )?)
109}
110
111pub fn mark_vectors_synced(
112    conn: &mut impl GenericClient,
113    project_id: &str,
114    file_path: &str,
115) -> anyhow::Result<bool> {
116    let updated = conn.execute(
117        "UPDATE code_indexed_files
118         SET vectors_synced = true
119         WHERE project_id = $1 AND file_path = $2",
120        &[&project_id, &file_path],
121    )?;
122    Ok(updated > 0)
123}
124
125pub fn mark_project_vectors_synced(
126    conn: &mut impl GenericClient,
127    project_id: &str,
128) -> anyhow::Result<u64> {
129    Ok(conn.execute(
130        "UPDATE code_indexed_files
131         SET vectors_synced = true
132         WHERE project_id = $1",
133        &[&project_id],
134    )?)
135}
136
137/// Return the vector sync state for an indexed file.
138///
139/// `None` means the file is not present in `code_indexed_files`; `Some(value)`
140/// means the file exists and reports that `vectors_synced` state.
141pub fn file_vectors_synced(
142    conn: &mut impl GenericClient,
143    project_id: &str,
144    file_path: &str,
145) -> anyhow::Result<Option<bool>> {
146    let synced = conn
147        .query_opt(
148            "SELECT vectors_synced
149             FROM code_indexed_files
150             WHERE project_id = $1 AND file_path = $2",
151            &[&project_id, &file_path],
152        )?
153        .map(|row| row.try_get::<_, bool>("vectors_synced"))
154        .transpose()?;
155    Ok(synced)
156}
157
158pub fn reset_vectors_sync_for_project(
159    conn: &mut impl GenericClient,
160    project_id: &str,
161) -> anyhow::Result<u64> {
162    Ok(conn.execute(
163        "UPDATE code_indexed_files
164         SET vectors_synced = false
165         WHERE project_id = $1",
166        &[&project_id],
167    )?)
168}
169
170fn read_imports_for_file(
171    conn: &mut impl GenericClient,
172    project_id: &str,
173    file_path: &str,
174) -> anyhow::Result<Vec<ImportRelation>> {
175    let rows = conn.query(
176        "SELECT source_file, target_module
177         FROM code_imports
178         WHERE project_id = $1 AND source_file = $2
179         ORDER BY target_module",
180        &[&project_id, &file_path],
181    )?;
182    rows.into_iter()
183        .map(|row| {
184            Ok(ImportRelation {
185                file_path: row.try_get("source_file")?,
186                module_name: row.try_get("target_module")?,
187            })
188        })
189        .collect()
190}
191
192fn read_symbols_for_file(
193    conn: &mut impl GenericClient,
194    project_id: &str,
195    file_path: &str,
196) -> anyhow::Result<Vec<Symbol>> {
197    let query = format!(
198        "SELECT {} FROM code_symbols s
199         WHERE s.project_id = $1 AND s.file_path = $2
200         ORDER BY s.line_start, s.byte_start",
201        symbol_select_columns("s")
202    );
203    let rows = conn.query(&query, &[&project_id, &file_path])?;
204    rows.iter().map(Symbol::from_row).collect()
205}
206
207fn read_calls_for_file(
208    conn: &mut impl GenericClient,
209    project_id: &str,
210    file_path: &str,
211) -> anyhow::Result<Vec<CallRelation>> {
212    let rows = conn.query(
213        "SELECT caller_symbol_id, callee_symbol_id, callee_name,
214                callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
215         FROM code_calls
216         WHERE project_id = $1 AND file_path = $2
217         ORDER BY line, caller_symbol_id, callee_name",
218        &[&project_id, &file_path],
219    )?;
220    rows.into_iter()
221        .map(|row| {
222            let target_kind: String = row.try_get("callee_target_kind")?;
223            let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
224            let callee_external_module: String = row.try_get("callee_external_module")?;
225            Ok(CallRelation {
226                caller_symbol_id: row.try_get("caller_symbol_id")?,
227                callee_symbol_id: non_empty(callee_symbol_id),
228                callee_name: row.try_get("callee_name")?,
229                callee_target_kind: call_target_kind_from_str(&target_kind)?,
230                callee_external_module: non_empty(callee_external_module),
231                file_path: row.try_get("file_path")?,
232                line: i64_to_usize(row.try_get("line")?, "line")?,
233            })
234        })
235        .collect()
236}
237
238fn non_empty(value: String) -> Option<String> {
239    if value.is_empty() { None } else { Some(value) }
240}
241
242fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
243    match value {
244        "symbol" => Ok(CallTargetKind::Symbol),
245        "unresolved" => Ok(CallTargetKind::Unresolved),
246        "external" => Ok(CallTargetKind::External),
247        other => bail!("unknown code_calls.callee_target_kind `{other}`"),
248    }
249}
250
251pub fn symbol_select_columns(alias: &str) -> String {
252    assert!(
253        safe_symbol_select_alias(alias),
254        "symbol_select_columns alias must be empty or a safe SQL identifier"
255    );
256    let prefix = if alias.is_empty() {
257        String::new()
258    } else {
259        format!("{alias}.")
260    };
261    format!(
262        "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
263         {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
264         {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
265         {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
266         {p}parent_symbol_id, {p}content_hash, {p}summary, \
267         {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
268        p = prefix
269    )
270}
271
272fn safe_symbol_select_alias(alias: &str) -> bool {
273    if alias.is_empty() {
274        return true;
275    }
276    let mut chars = alias.chars();
277    chars
278        .next()
279        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
280        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn symbol_select_columns_accepts_empty_or_safe_alias() {
289        assert!(symbol_select_columns("").starts_with("id, project_id"));
290        assert!(symbol_select_columns("cs").starts_with("cs.id, cs.project_id"));
291        assert!(symbol_select_columns("_symbols1").starts_with("_symbols1.id"));
292    }
293
294    #[test]
295    #[should_panic(expected = "safe SQL identifier")]
296    fn symbol_select_columns_rejects_unsafe_alias() {
297        let _ = symbol_select_columns("cs;DROP TABLE code_symbols");
298    }
299}