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.iter().map(call_relation_from_row).collect()
221}
222
223fn call_relation_from_row(row: &postgres::Row) -> anyhow::Result<CallRelation> {
224    let target_kind: String = row.try_get("callee_target_kind")?;
225    let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
226    let callee_external_module: String = row.try_get("callee_external_module")?;
227    Ok(CallRelation {
228        caller_symbol_id: row.try_get("caller_symbol_id")?,
229        callee_symbol_id: non_empty(callee_symbol_id),
230        callee_name: row.try_get("callee_name")?,
231        callee_target_kind: call_target_kind_from_str(&target_kind)?,
232        callee_external_module: non_empty(callee_external_module),
233        file_path: row.try_get("file_path")?,
234        line: i64_to_usize(row.try_get("line")?, "line")?,
235    })
236}
237
238/// Read the pending `local_import` calls written for `file_paths` during the
239/// current index run. Each returned `CallRelation` carries its candidate target
240/// files in `callee_external_module` (see `CallRelation::with_local_import_target`).
241pub fn read_local_import_calls(
242    conn: &mut impl GenericClient,
243    project_id: &str,
244    file_paths: &[String],
245) -> anyhow::Result<Vec<CallRelation>> {
246    if file_paths.is_empty() {
247        return Ok(Vec::new());
248    }
249    let rows = conn.query(
250        "SELECT caller_symbol_id, callee_symbol_id, callee_name,
251                callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
252         FROM code_calls
253         WHERE project_id = $1 AND file_path = ANY($2)
254           AND callee_target_kind = 'local_import'
255         ORDER BY file_path, line, caller_symbol_id, callee_name",
256        &[&project_id, &file_paths],
257    )?;
258    rows.iter().map(call_relation_from_row).collect()
259}
260
261pub fn read_project_local_import_calls(
262    conn: &mut impl GenericClient,
263    project_id: &str,
264) -> anyhow::Result<Vec<CallRelation>> {
265    let rows = conn.query(
266        "SELECT caller_symbol_id, callee_symbol_id, callee_name,
267                callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
268         FROM code_calls
269         WHERE project_id = $1 AND callee_target_kind = 'local_import'
270         ORDER BY file_path, line, caller_symbol_id, callee_name",
271        &[&project_id],
272    )?;
273    rows.iter().map(call_relation_from_row).collect()
274}
275
276/// Resolve a cross-file local-import call target to its canonical `code_symbols`
277/// id by `(candidate files, original name)`. Returns the real indexed id (no
278/// UUID recompute, so a phantom edge is structurally impossible), or `None` when
279/// nothing matches or the match is ambiguous.
280///
281/// Preference tiers, highest first:
282/// 1. top-level (`parent_symbol_id IS NULL`) `function`/`class`
283/// 2. `method`
284/// 3. module-scoped `function` (Elixir `def` inside `defmodule`)
285/// 4. top-level `type`
286///
287/// The best non-empty tier must contain exactly one symbol; otherwise the call
288/// degrades to unresolved rather than risk a wrong edge.
289pub fn resolve_local_callee_symbol_id(
290    conn: &mut impl GenericClient,
291    project_id: &str,
292    target_files: &[String],
293    name: &str,
294) -> anyhow::Result<Option<String>> {
295    if target_files.is_empty() || name.is_empty() {
296        return Ok(None);
297    }
298    let rows = conn.query(
299        "SELECT id, kind, parent_symbol_id
300         FROM code_symbols
301         WHERE project_id = $1 AND file_path = ANY($2) AND name = $3
302         ORDER BY file_path, byte_start",
303        &[&project_id, &target_files, &name],
304    )?;
305
306    let candidates: Vec<LocalCalleeCandidate> = rows
307        .iter()
308        .map(|row| {
309            let id: String = row.try_get("id")?;
310            let kind: String = row.try_get("kind")?;
311            let parent_symbol_id: Option<String> = row.try_get("parent_symbol_id")?;
312            Ok::<_, anyhow::Error>(LocalCalleeCandidate {
313                id,
314                kind,
315                parent_symbol_id,
316            })
317        })
318        .collect::<Result<_, _>>()?;
319
320    Ok(select_local_callee_candidate_id(&candidates))
321}
322
323pub fn resolve_default_import_symbol_id(
324    conn: &mut impl GenericClient,
325    project_id: &str,
326    target_files: &[String],
327) -> anyhow::Result<Option<String>> {
328    if target_files.is_empty() {
329        return Ok(None);
330    }
331    let target_kinds = ["function", "class", "type"];
332    let rows = conn.query(
333        "SELECT id, kind, parent_symbol_id
334         FROM code_symbols
335         WHERE project_id = $1 AND file_path = ANY($2)
336           AND parent_symbol_id IS NULL
337           AND kind = ANY($3)
338         ORDER BY file_path, byte_start",
339        &[&project_id, &target_files, &target_kinds.as_slice()],
340    )?;
341
342    let candidates: Vec<LocalCalleeCandidate> = rows
343        .iter()
344        .map(|row| {
345            let id: String = row.try_get("id")?;
346            let kind: String = row.try_get("kind")?;
347            let parent_symbol_id: Option<String> = row.try_get("parent_symbol_id")?;
348            Ok::<_, anyhow::Error>(LocalCalleeCandidate {
349                id,
350                kind,
351                parent_symbol_id,
352            })
353        })
354        .collect::<Result<_, _>>()?;
355
356    Ok(select_default_import_candidate_id(&candidates))
357}
358
359#[derive(Debug)]
360struct LocalCalleeCandidate {
361    id: String,
362    kind: String,
363    parent_symbol_id: Option<String>,
364}
365
366fn select_local_callee_candidate_id(candidates: &[LocalCalleeCandidate]) -> Option<String> {
367    let top_level: Vec<&String> = candidates
368        .iter()
369        .filter(|candidate| {
370            candidate.parent_symbol_id.is_none()
371                && matches!(candidate.kind.as_str(), "function" | "class")
372        })
373        .map(|candidate| &candidate.id)
374        .collect();
375    if !top_level.is_empty() {
376        return unique_id(&top_level);
377    }
378
379    let methods: Vec<&String> = candidates
380        .iter()
381        .filter(|candidate| candidate.kind == "method")
382        .map(|candidate| &candidate.id)
383        .collect();
384    if !methods.is_empty() {
385        return unique_id(&methods);
386    }
387
388    // Elixir `def greet(name)` remains a function under its defmodule parent.
389    // Non-Elixir nested functions are normalized to method in parser::link_parents,
390    // so this tier only catches module-scoped Elixir functions. Multi-clause or
391    // multi-arity defs still produce multiple same-name rows; the unique guard
392    // keeps those ambiguous calls unresolved until resolution tracks arity.
393    let module_scoped_functions: Vec<&String> = candidates
394        .iter()
395        .filter(|candidate| candidate.parent_symbol_id.is_some() && candidate.kind == "function")
396        .map(|candidate| &candidate.id)
397        .collect();
398    if !module_scoped_functions.is_empty() {
399        return unique_id(&module_scoped_functions);
400    }
401
402    // A top-level type (struct/enum/protocol/interface/...) is a valid
403    // construction/initializer target. Checked last — only when no function,
404    // class, or method matched — so it never overrides existing resolution for
405    // any language; it just lets languages whose constructible types are kind
406    // `type` (e.g. Swift structs/enums) resolve their initializer calls.
407    let types: Vec<&String> = candidates
408        .iter()
409        .filter(|candidate| candidate.parent_symbol_id.is_none() && candidate.kind == "type")
410        .map(|candidate| &candidate.id)
411        .collect();
412    unique_id(&types)
413}
414
415fn select_default_import_candidate_id(candidates: &[LocalCalleeCandidate]) -> Option<String> {
416    let top_level: Vec<&String> = candidates
417        .iter()
418        .filter(|candidate| {
419            candidate.parent_symbol_id.is_none()
420                && matches!(candidate.kind.as_str(), "function" | "class" | "type")
421        })
422        .map(|candidate| &candidate.id)
423        .collect();
424    unique_id(&top_level)
425}
426
427fn unique_id(ids: &[&String]) -> Option<String> {
428    match ids {
429        [single] => Some((*single).clone()),
430        _ => None,
431    }
432}
433
434fn non_empty(value: String) -> Option<String> {
435    if value.is_empty() { None } else { Some(value) }
436}
437
438fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
439    match value {
440        "symbol" => Ok(CallTargetKind::Symbol),
441        "unresolved" => Ok(CallTargetKind::Unresolved),
442        "external" => Ok(CallTargetKind::External),
443        // A completed index run rewrites every `local_import` row to `symbol` or
444        // `unresolved`, but an interrupted run can leave one behind; parse it so
445        // read-back (and the post-write resolver) never hard-errors.
446        "local_import" => Ok(CallTargetKind::LocalImport),
447        other => bail!("unknown code_calls.callee_target_kind `{other}`"),
448    }
449}
450
451pub fn symbol_select_columns(alias: &str) -> String {
452    assert!(
453        safe_symbol_select_alias(alias),
454        "symbol_select_columns alias must be empty or a safe SQL identifier"
455    );
456    let prefix = if alias.is_empty() {
457        String::new()
458    } else {
459        format!("{alias}.")
460    };
461    format!(
462        "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
463         {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
464         {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
465         {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
466         {p}parent_symbol_id, {p}content_hash, {p}summary, \
467         {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
468        p = prefix
469    )
470}
471
472fn safe_symbol_select_alias(alias: &str) -> bool {
473    if alias.is_empty() {
474        return true;
475    }
476    let mut chars = alias.chars();
477    chars
478        .next()
479        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
480        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    fn code_symbol_row(
488        id: &str,
489        kind: &str,
490        parent_symbol_id: Option<&str>,
491    ) -> LocalCalleeCandidate {
492        LocalCalleeCandidate {
493            id: id.to_string(),
494            kind: kind.to_string(),
495            parent_symbol_id: parent_symbol_id.map(str::to_string),
496        }
497    }
498
499    #[test]
500    fn resolves_unique_module_scoped_function_candidate() {
501        let candidates = [code_symbol_row("greet-fn", "function", Some("app-greeter"))];
502
503        assert_eq!(
504            select_local_callee_candidate_id(&candidates),
505            Some("greet-fn".to_string())
506        );
507    }
508
509    #[test]
510    fn method_tier_precedes_module_scoped_function_candidates() {
511        let candidates = [
512            code_symbol_row("greet-fn", "function", Some("app-greeter")),
513            code_symbol_row("greet-method", "method", Some("app-greeter")),
514        ];
515
516        assert_eq!(
517            select_local_callee_candidate_id(&candidates),
518            Some("greet-method".to_string())
519        );
520    }
521
522    #[test]
523    fn leaves_ambiguous_module_scoped_function_candidates_unresolved() {
524        let candidates = [
525            code_symbol_row("greet-1", "function", Some("app-greeter")),
526            code_symbol_row("greet-2", "function", Some("app-greeter")),
527        ];
528
529        assert_eq!(select_local_callee_candidate_id(&candidates), None);
530    }
531
532    #[test]
533    fn default_import_selector_resolves_unique_top_level_candidate() {
534        let candidates = [
535            code_symbol_row("helper", "function", None),
536            code_symbol_row("nested", "function", Some("helper")),
537            code_symbol_row("method", "method", Some("helper")),
538        ];
539
540        assert_eq!(
541            select_default_import_candidate_id(&candidates),
542            Some("helper".to_string())
543        );
544    }
545
546    #[test]
547    fn default_import_selector_leaves_ambiguous_top_level_candidates_unresolved() {
548        let candidates = [
549            code_symbol_row("helper", "function", None),
550            code_symbol_row("Widget", "class", None),
551        ];
552
553        assert_eq!(select_default_import_candidate_id(&candidates), None);
554    }
555
556    #[test]
557    fn symbol_select_columns_accepts_empty_or_safe_alias() {
558        assert!(symbol_select_columns("").starts_with("id, project_id"));
559        assert!(symbol_select_columns("cs").starts_with("cs.id, cs.project_id"));
560        assert!(symbol_select_columns("_symbols1").starts_with("_symbols1.id"));
561    }
562
563    #[test]
564    #[should_panic(expected = "safe SQL identifier")]
565    fn symbol_select_columns_rejects_unsafe_alias() {
566        let _ = symbol_select_columns("cs;DROP TABLE code_symbols");
567    }
568}