Skip to main content

gobby_code/graph/code_graph/
write.rs

1//! Code-index graph projection writes.
2//!
3//! This is the intentional exception to the broader "Gobby-owned stores are
4//! externally managed" rule: `gcode` owns the code-index graph projection and
5//! writes FalkorDB `Code*` nodes/edges derived from its PostgreSQL index rows.
6
7use crate::config::Context;
8use crate::models::{CallRelation, ImportRelation, Symbol};
9use gobby_core::degradation::ServiceState;
10use gobby_core::falkor::GraphClient;
11use serde_json::Value;
12use std::collections::{BTreeSet, HashSet};
13
14use super::GraphReadError;
15use super::connection::with_required_core_graph;
16
17mod deletion;
18mod mutation;
19mod support;
20mod sync_plan;
21
22pub(crate) use deletion::{
23    cleanup_orphans_queries, clear_all_code_index_query, clear_project_query,
24    count_file_projection_nodes_query, delete_file_graph_queries, delete_file_node_query,
25    project_file_path_queries,
26};
27pub use mutation::call_target_id;
28pub(in crate::graph::code_graph) use mutation::{import_graph_items, partition_call_graph_items};
29
30use deletion::delete_stale_file_graph_queries;
31use mutation::{
32    SyncFileMutation, add_definitions_query, add_external_calls_query, add_imports_query,
33    add_symbol_calls_query, add_unresolved_calls_query, definition_graph_symbols,
34    ensure_file_node_query, new_sync_token,
35};
36use support::execute_write_query;
37use sync_plan::plan_sync_batches;
38
39const PROJECT_INDEXED_LABELS: &[&str] = &[
40    "CodeFile",
41    "CodeSymbol",
42    "CodeModule",
43    "UnresolvedCallee",
44    "ExternalSymbol",
45];
46
47pub struct CodeGraph<'a> {
48    project_id: &'a str,
49    client: &'a mut GraphClient,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GraphOrphanCleanup {
54    pub stale_files_deleted: usize,
55    pub graph_nodes_deleted: usize,
56}
57
58impl<'a> CodeGraph<'a> {
59    pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
60        Self { project_id, client }
61    }
62
63    pub fn sync_file(
64        &mut self,
65        file_path: &str,
66        imports: &[ImportRelation],
67        definitions: &[Symbol],
68        calls: &[CallRelation],
69        cleanup_orphans: bool,
70    ) -> anyhow::Result<usize> {
71        let sync_token = new_sync_token(file_path);
72        let import_items = import_graph_items(file_path, imports);
73        let symbols = definition_graph_symbols(definitions);
74        let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
75        let relationship_count = import_items.len()
76            + symbols.len()
77            + call_groups.symbol.len()
78            + call_groups.external.len()
79            + call_groups.unresolved.len();
80        // Issue the mutation as bounded batches so no single FalkorDB request
81        // grows unbounded for pathological files (gobby-cli #678).
82        for query in plan_sync_batches(SyncFileMutation {
83            project_id: self.project_id,
84            file_path,
85            symbol_count: definitions.len(),
86            imports: &import_items,
87            symbols: &symbols,
88            calls: &call_groups,
89            sync_token: &sync_token,
90        })? {
91            execute_write_query(self.client, query)?;
92        }
93        // Stale delete is token-only: every current row was just written with the
94        // new sync_token, so a token mismatch alone identifies stale rows — no
95        // (potentially unbounded) symbol-id list is needed.
96        self.delete_stale_file_graph(file_path, &sync_token)?;
97        if cleanup_orphans {
98            self.cleanup_orphans()?;
99        }
100        Ok(relationship_count)
101    }
102
103    pub fn ensure_project_indexes(&mut self) -> anyhow::Result<()> {
104        for label in PROJECT_INDEXED_LABELS {
105            self.client.ensure_exact_node_index(label, "project")?;
106        }
107        Ok(())
108    }
109
110    pub fn ensure_file_node(
111        &mut self,
112        file_path: &str,
113        symbol_count: usize,
114        sync_token: &str,
115    ) -> anyhow::Result<()> {
116        execute_write_query(
117            self.client,
118            ensure_file_node_query(self.project_id, file_path, symbol_count, sync_token)?,
119        )
120    }
121
122    pub fn add_imports(
123        &mut self,
124        file_path: &str,
125        imports: &[ImportRelation],
126        sync_token: &str,
127    ) -> anyhow::Result<usize> {
128        let items = import_graph_items(file_path, imports);
129        if items.is_empty() {
130            return Ok(0);
131        }
132        let written = items.len();
133        execute_write_query(
134            self.client,
135            add_imports_query(self.project_id, &items, sync_token)?,
136        )?;
137        Ok(written)
138    }
139
140    pub fn add_definitions(
141        &mut self,
142        file_path: &str,
143        definitions: &[Symbol],
144        sync_token: &str,
145    ) -> anyhow::Result<usize> {
146        let symbols = definitions
147            .iter()
148            .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
149            .collect::<Vec<_>>();
150        if symbols.is_empty() {
151            return Ok(0);
152        }
153        let written = symbols.len();
154        execute_write_query(
155            self.client,
156            add_definitions_query(self.project_id, file_path, &symbols, sync_token)?,
157        )?;
158        Ok(written)
159    }
160
161    pub fn add_calls(
162        &mut self,
163        file_path: &str,
164        calls: &[CallRelation],
165        sync_token: &str,
166    ) -> anyhow::Result<usize> {
167        let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
168
169        let mut written = 0;
170        if !call_groups.symbol.is_empty() {
171            written += call_groups.symbol.len();
172            execute_write_query(
173                self.client,
174                add_symbol_calls_query(self.project_id, &call_groups.symbol, sync_token)?,
175            )?;
176        }
177        if !call_groups.external.is_empty() {
178            written += call_groups.external.len();
179            execute_write_query(
180                self.client,
181                add_external_calls_query(self.project_id, &call_groups.external, sync_token)?,
182            )?;
183        }
184        if !call_groups.unresolved.is_empty() {
185            written += call_groups.unresolved.len();
186            execute_write_query(
187                self.client,
188                add_unresolved_calls_query(self.project_id, &call_groups.unresolved, sync_token)?,
189            )?;
190        }
191        Ok(written)
192    }
193
194    pub fn delete_stale_file_graph(
195        &mut self,
196        file_path: &str,
197        sync_token: &str,
198    ) -> anyhow::Result<()> {
199        for query in delete_stale_file_graph_queries(self.project_id, file_path, sync_token)? {
200            execute_write_query(self.client, query)?;
201        }
202        Ok(())
203    }
204
205    pub fn delete_file_graph(
206        &mut self,
207        file_path: &str,
208        current_symbol_ids: &[String],
209    ) -> anyhow::Result<()> {
210        for query in delete_file_graph_queries(self.project_id, file_path, current_symbol_ids)? {
211            execute_write_query(self.client, query)?;
212        }
213        Ok(())
214    }
215
216    pub fn delete_file_node(&mut self, file_path: &str) -> anyhow::Result<()> {
217        execute_write_query(
218            self.client,
219            delete_file_node_query(self.project_id, file_path)?,
220        )
221    }
222
223    pub fn delete_file_projection(&mut self, file_path: &str) -> anyhow::Result<()> {
224        self.delete_file_graph(file_path, &[])?;
225        self.delete_file_node(file_path)?;
226        self.cleanup_orphans()
227    }
228
229    pub fn cleanup_orphans(&mut self) -> anyhow::Result<()> {
230        for query in cleanup_orphans_queries(self.project_id)? {
231            execute_write_query(self.client, query)?;
232        }
233        Ok(())
234    }
235
236    pub fn cleanup_deleted_files(
237        &mut self,
238        indexed_file_paths: &HashSet<String>,
239    ) -> anyhow::Result<GraphOrphanCleanup> {
240        let graph_file_paths = self.project_file_paths()?;
241        let stale_file_paths = graph_file_paths
242            .into_iter()
243            .filter(|file_path| !indexed_file_paths.contains(file_path))
244            .collect::<Vec<_>>();
245        let mut graph_nodes_deleted = 0;
246
247        for file_path in &stale_file_paths {
248            graph_nodes_deleted += self.count_file_projection_nodes(file_path)?;
249            self.delete_file_graph(file_path, &[])?;
250            self.delete_file_node(file_path)?;
251        }
252
253        self.cleanup_orphans()?;
254        Ok(GraphOrphanCleanup {
255            stale_files_deleted: stale_file_paths.len(),
256            graph_nodes_deleted,
257        })
258    }
259
260    fn project_file_paths(&mut self) -> anyhow::Result<BTreeSet<String>> {
261        let mut file_paths = BTreeSet::new();
262        for query in project_file_path_queries(self.project_id)? {
263            let crate::graph::typed_query::TypedQuery { cypher, params } = query;
264            for row in self.client.query(&cypher, Some(params))? {
265                if let Some(file_path) = row.get("path").and_then(Value::as_str) {
266                    file_paths.insert(file_path.to_string());
267                }
268            }
269        }
270        Ok(file_paths)
271    }
272
273    fn count_file_projection_nodes(&mut self, file_path: &str) -> anyhow::Result<usize> {
274        let query = count_file_projection_nodes_query(self.project_id, file_path)?;
275        let crate::graph::typed_query::TypedQuery { cypher, params } = query;
276        let rows = self.client.query(&cypher, Some(params))?;
277        Ok(rows
278            .first()
279            .and_then(|row| row.get("nodes"))
280            .and_then(value_to_usize)
281            .unwrap_or(0))
282    }
283
284    pub fn clear_project(&mut self) -> anyhow::Result<()> {
285        execute_write_query(self.client, clear_project_query(self.project_id)?)
286    }
287}
288
289fn value_to_usize(value: &Value) -> Option<usize> {
290    if let Some(value) = value.as_u64() {
291        return usize::try_from(value).ok();
292    }
293    value.as_i64().and_then(|value| usize::try_from(value).ok())
294}
295
296pub fn sync_file_graph(
297    ctx: &Context,
298    file_path: &str,
299    imports: &[ImportRelation],
300    definitions: &[Symbol],
301    calls: &[CallRelation],
302    cleanup_orphans: bool,
303) -> anyhow::Result<usize> {
304    with_code_graph(ctx, |graph| {
305        graph.sync_file(file_path, imports, definitions, calls, cleanup_orphans)
306    })
307}
308
309pub fn with_code_graph<T>(
310    ctx: &Context,
311    f: impl FnOnce(&mut CodeGraph<'_>) -> anyhow::Result<T>,
312) -> anyhow::Result<T> {
313    with_required_core_graph(ctx, |client| {
314        let mut graph = CodeGraph::new(&ctx.project_id, client);
315        graph.ensure_project_indexes()?;
316        f(&mut graph)
317    })
318}
319
320pub fn delete_file_graph(
321    ctx: &Context,
322    file_path: &str,
323    current_symbol_ids: &[String],
324) -> anyhow::Result<()> {
325    with_required_core_graph(ctx, |client| {
326        CodeGraph::new(&ctx.project_id, client).delete_file_graph(file_path, current_symbol_ids)
327    })
328}
329
330pub fn delete_file_projection(ctx: &Context, file_path: &str) -> anyhow::Result<()> {
331    with_required_core_graph(ctx, |client| {
332        CodeGraph::new(&ctx.project_id, client).delete_file_projection(file_path)
333    })
334}
335
336pub fn cleanup_orphans(ctx: &Context) -> anyhow::Result<()> {
337    with_code_graph(ctx, |graph| graph.cleanup_orphans())
338}
339
340pub fn cleanup_deleted_files(
341    ctx: &Context,
342    indexed_file_paths: &HashSet<String>,
343) -> anyhow::Result<GraphOrphanCleanup> {
344    with_code_graph(ctx, |graph| graph.cleanup_deleted_files(indexed_file_paths))
345}
346
347pub fn clear_project(ctx: &Context) -> anyhow::Result<()> {
348    with_required_core_graph(ctx, |client| {
349        CodeGraph::new(&ctx.project_id, client).clear_project()
350    })
351}
352
353pub fn clear_all_code_index(config: &crate::config::FalkorConfig) -> anyhow::Result<()> {
354    let connection_config = config.connection_config();
355    match gobby_core::falkor::with_graph(
356        Some(&connection_config),
357        &config.graph_name,
358        None,
359        |client| execute_write_query(client, clear_all_code_index_query()?).map(Some),
360    ) {
361        Ok((Some(()), ServiceState::Available)) => Ok(()),
362        Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
363        Ok((_, ServiceState::Unreachable { message })) => {
364            log::warn!("FalkorDB was unreachable while clearing code graph: {message}");
365            Err(GraphReadError::Unreachable { message }.into())
366        }
367        Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
368            message: "graph clear returned no value".to_string(),
369        }
370        .into()),
371        Err(error) => Err(GraphReadError::QueryFailed {
372            message: format!("{error:#}"),
373        }
374        .into()),
375    }
376}