Skip to main content

gobby_code/
falkor.rs

1//! Compatibility facade for FalkorDB graph queries.
2//!
3//! The reusable projection/query implementation lives under
4//! `crate::graph::code_graph`; this module keeps the Phase 7 public surface
5//! available for downstream callers that still import `crate::falkor`.
6
7use std::collections::HashMap;
8
9use gobby_core::falkor::GraphClient;
10
11use crate::config::{Context, FalkorConfig};
12use crate::graph::typed_query;
13use crate::models::GraphResult;
14
15const MAX_GRAPH_LIMIT: usize = 100;
16
17/// Row from a FalkorDB query response.
18pub type Row = gobby_core::falkor::Row;
19
20/// Blocking FalkorDB graph client.
21pub struct FalkorClient {
22    client: GraphClient,
23}
24
25impl FalkorClient {
26    pub fn from_config(config: &FalkorConfig) -> anyhow::Result<Self> {
27        let connection_config = config.connection_config();
28        let client = GraphClient::from_config(&connection_config, &config.graph_name)?;
29        Ok(Self { client })
30    }
31
32    /// Execute a Cypher query and return parsed rows.
33    pub fn query(
34        &mut self,
35        cypher: &str,
36        params: Option<HashMap<String, String>>,
37    ) -> anyhow::Result<Vec<Row>> {
38        self.client.query(cypher, params)
39    }
40
41    /// Execute a typed query after its parameters have been rendered safely.
42    pub fn query_typed(&mut self, query: typed_query::TypedQuery) -> anyhow::Result<Vec<Row>> {
43        let typed_query::TypedQuery { cypher, params } = query;
44        self.query(&cypher, Some(params))
45    }
46
47    pub fn with_core_client<T>(
48        &mut self,
49        f: impl FnOnce(&mut GraphClient) -> anyhow::Result<T>,
50    ) -> anyhow::Result<T> {
51        f(&mut self.client)
52    }
53}
54
55pub fn cypher_string_literal(s: &str) -> String {
56    crate::graph::typed_query::cypher_string_literal(s)
57}
58
59pub fn id_list_literal(ids: &[String]) -> String {
60    typed_query::id_list_literal(ids)
61}
62
63pub fn clamp_offset(offset: usize) -> usize {
64    offset.min(MAX_GRAPH_LIMIT)
65}
66
67pub fn count_callers_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
68    crate::graph::code_graph::count_callers_query(project_id, symbol_id)
69}
70
71pub fn count_usages_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
72    crate::graph::code_graph::count_usages_query(project_id, symbol_id)
73}
74
75pub fn find_callers_query(
76    project_id: &str,
77    symbol_id: &str,
78    offset: usize,
79    limit: usize,
80) -> (String, HashMap<String, String>) {
81    crate::graph::code_graph::find_callers_query(project_id, symbol_id, offset, limit)
82}
83
84pub fn find_usages_query(
85    project_id: &str,
86    symbol_id: &str,
87    offset: usize,
88    limit: usize,
89) -> (String, HashMap<String, String>) {
90    crate::graph::code_graph::find_usages_query(project_id, symbol_id, offset, limit)
91}
92
93pub fn find_callers_batch_query(
94    project_id: &str,
95    symbol_ids: &[String],
96    limit: usize,
97) -> (String, HashMap<String, String>) {
98    crate::graph::code_graph::find_callers_batch_query(project_id, symbol_ids, limit)
99}
100
101pub fn find_callees_batch_query(
102    project_id: &str,
103    symbol_ids: &[String],
104    limit: usize,
105) -> (String, HashMap<String, String>) {
106    crate::graph::code_graph::find_callees_batch_query(project_id, symbol_ids, limit)
107}
108
109pub fn get_imports_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
110    crate::graph::code_graph::get_imports_query(project_id, file_path)
111}
112
113pub fn blast_radius_query(depth: usize, limit: usize) -> String {
114    crate::graph::code_graph::blast_radius_query(depth, limit)
115}
116
117pub fn with_falkor<T>(
118    ctx: &Context,
119    default: T,
120    f: impl FnOnce(&mut FalkorClient) -> anyhow::Result<T>,
121) -> anyhow::Result<T> {
122    let Some(config) = &ctx.falkordb else {
123        return Ok(default);
124    };
125
126    let mut client = match FalkorClient::from_config(config) {
127        Ok(client) => client,
128        Err(e) => {
129            if !ctx.quiet {
130                eprintln!("Warning: FalkorDB connection failed: {e}");
131            }
132            return Ok(default);
133        }
134    };
135
136    match f(&mut client) {
137        Ok(value) => Ok(value),
138        Err(e) => {
139            if !ctx.quiet {
140                eprintln!("Warning: FalkorDB query failed: {e}");
141            }
142            Ok(default)
143        }
144    }
145}
146
147/// Count callers of a symbol.
148pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
149    crate::graph::code_graph::count_callers(ctx, symbol_id)
150}
151
152/// Count incoming call usages of a symbol.
153pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
154    crate::graph::code_graph::count_usages(ctx, symbol_id)
155}
156
157/// Find symbols that call the given symbol id.
158pub fn find_callers(
159    ctx: &Context,
160    symbol_id: &str,
161    offset: usize,
162    limit: usize,
163) -> anyhow::Result<Vec<GraphResult>> {
164    crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)
165}
166
167/// Find incoming CALLS usages for a canonical, unresolved, or external target.
168pub fn find_usages(
169    ctx: &Context,
170    symbol_id: &str,
171    offset: usize,
172    limit: usize,
173) -> anyhow::Result<Vec<GraphResult>> {
174    crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)
175}
176
177/// Find symbols that call any of the given target ids.
178pub fn find_callers_batch(
179    ctx: &Context,
180    symbol_ids: &[String],
181    limit: usize,
182) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
183    let mut grouped = HashMap::new();
184    for symbol_id in symbol_ids {
185        grouped.insert(
186            symbol_id.clone(),
187            crate::graph::code_graph::find_callers(ctx, symbol_id, 0, limit)?,
188        );
189    }
190    Ok(grouped)
191}
192
193/// Find call targets reached by any of the given source ids.
194pub fn find_callees_batch(
195    ctx: &Context,
196    symbol_ids: &[String],
197    limit: usize,
198) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
199    let mut grouped = HashMap::new();
200    for symbol_id in symbol_ids {
201        grouped.insert(
202            symbol_id.clone(),
203            crate::graph::code_graph::find_callees_batch(
204                ctx,
205                std::slice::from_ref(symbol_id),
206                limit,
207            )?,
208        );
209    }
210    Ok(grouped)
211}
212
213/// Get import graph for a file.
214pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
215    crate::graph::code_graph::get_imports(ctx, file_path)
216}
217
218/// Find transitive blast radius of changing a symbol.
219pub fn blast_radius(
220    ctx: &Context,
221    symbol_id: &str,
222    depth: usize,
223) -> anyhow::Result<Vec<GraphResult>> {
224    crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)
225}
226
227#[cfg(test)]
228fn row_to_graph_result(row: &Row) -> GraphResult {
229    crate::graph::code_graph::row_to_graph_result(row)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use serde_json::json;
236
237    const CALL_TARGET_FRAGMENT: &str =
238        "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
239
240    fn assert_no_numeric_or_list_placeholders(query: &str) {
241        assert!(!query.contains("$offset"), "{query}");
242        assert!(!query.contains("$limit"), "{query}");
243        assert!(!query.contains("$ids"), "{query}");
244    }
245
246    #[test]
247    fn cypher_string_literal_escapes_single_quotes_and_backslashes() {
248        assert_eq!(
249            cypher_string_literal("module\\path'symbol"),
250            "'module\\\\path\\'symbol'"
251        );
252    }
253
254    #[test]
255    fn find_callers_query_interpolates_numeric_skip_and_limit() {
256        let (query, params) = find_callers_query("project-1", "symbol-1", 250, 0);
257
258        assert!(query.contains("SKIP 100 LIMIT 1"), "{query}");
259        assert_no_numeric_or_list_placeholders(&query);
260        assert_eq!(
261            params.get("project").map(String::as_str),
262            Some("'project-1'")
263        );
264        assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
265    }
266
267    #[test]
268    fn find_usages_query_clamps_numeric_skip_and_limit() {
269        let (query, params) = find_usages_query("project-1", "symbol-1", 250, 250);
270
271        assert!(query.contains("SKIP 100 LIMIT 100"), "{query}");
272        assert_no_numeric_or_list_placeholders(&query);
273        assert_eq!(
274            params.get("project").map(String::as_str),
275            Some("'project-1'")
276        );
277        assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
278    }
279
280    #[test]
281    fn batch_query_uses_one_interpolated_in_list() {
282        let (query, params) =
283            find_callers_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
284
285        assert_eq!(query.matches(" IN [").count(), 1, "{query}");
286        assert!(query.contains("target.id IN ['a', 'b\\'\\\\c']"), "{query}");
287        assert!(query.contains("LIMIT 100"), "{query}");
288        assert_no_numeric_or_list_placeholders(&query);
289        assert_eq!(
290            params.get("project").map(String::as_str),
291            Some("'project-1'")
292        );
293    }
294
295    #[test]
296    fn blast_radius_query_clamps_depth_and_interpolates_limit() {
297        let query = blast_radius_query(99, 250);
298
299        assert!(query.contains(CALL_TARGET_FRAGMENT), "{query}");
300        assert!(query.contains("[:CALLS*1..5]"), "{query}");
301        assert!(query.contains("LIMIT 100"), "{query}");
302        assert_no_numeric_or_list_placeholders(&query);
303    }
304
305    #[test]
306    fn row_to_graph_result_prefers_blast_radius_node_fields() {
307        let row = Row::from([
308            ("node_id".to_string(), json!("sym-1")),
309            ("node_name".to_string(), json!("foo")),
310            ("file_path".to_string(), json!("src/main.py")),
311            ("line".to_string(), json!(42)),
312            ("rel_type".to_string(), json!("call")),
313            ("distance".to_string(), json!(2)),
314        ]);
315
316        let result = row_to_graph_result(&row);
317
318        assert_eq!(result.id, "sym-1");
319        assert_eq!(result.name, "foo");
320        assert_eq!(result.file_path, "src/main.py");
321        assert_eq!(result.line, 42);
322        assert_eq!(result.relation.as_deref(), Some("call"));
323        assert_eq!(result.distance, Some(2));
324    }
325
326    #[test]
327    fn phase7_query_helpers_preserve_safe_literals_clamping_and_project_scope() {
328        let project_id = "project\n'one";
329        let symbol_id = "symbol\"\\'two";
330        let expected_project = cypher_string_literal(project_id);
331        let expected_symbol = cypher_string_literal(symbol_id);
332
333        let (callers, caller_params) = find_callers_query(project_id, symbol_id, 250, 0);
334        assert!(callers.contains(CALL_TARGET_FRAGMENT), "{callers}");
335        assert!(callers.contains("SKIP 100 LIMIT 1"), "{callers}");
336        assert_no_numeric_or_list_placeholders(&callers);
337        assert_eq!(caller_params.get("project"), Some(&expected_project));
338        assert_eq!(caller_params.get("id"), Some(&expected_symbol));
339
340        let (usages, usage_params) = find_usages_query(project_id, symbol_id, 250, 250);
341        assert!(usages.contains(CALL_TARGET_FRAGMENT), "{usages}");
342        assert!(usages.contains("SKIP 100 LIMIT 100"), "{usages}");
343        assert_no_numeric_or_list_placeholders(&usages);
344        assert_eq!(usage_params.get("project"), Some(&expected_project));
345        assert_eq!(usage_params.get("id"), Some(&expected_symbol));
346
347        let ids = ["a".to_string(), "b\n\"'\\c".to_string()];
348        let expected_ids = id_list_literal(&ids);
349        let (batch, batch_params) = find_callers_batch_query(project_id, &ids, 250);
350        assert!(
351            batch.contains(&format!("target.id IN [{expected_ids}]")),
352            "{batch}"
353        );
354        assert!(batch.contains("LIMIT 100"), "{batch}");
355        assert_no_numeric_or_list_placeholders(&batch);
356        assert_eq!(batch_params.get("project"), Some(&expected_project));
357
358        let blast = blast_radius_query(99, 250);
359        assert!(blast.contains(CALL_TARGET_FRAGMENT), "{blast}");
360        assert!(blast.contains("[:CALLS*1..5]"), "{blast}");
361        assert!(blast.contains("LIMIT 100"), "{blast}");
362        assert_no_numeric_or_list_placeholders(&blast);
363    }
364}