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
63/// Bound user-supplied offsets to the graph query page cap.
64///
65/// Callers may pass any non-negative offset, but FalkorDB reads stay capped at
66/// `MAX_GRAPH_LIMIT` so accidental deep pages cannot trigger unbounded scans.
67pub fn clamp_offset(offset: usize) -> usize {
68    offset.min(MAX_GRAPH_LIMIT)
69}
70
71pub fn count_callers_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
72    crate::graph::code_graph::count_callers_query(project_id, symbol_id)
73}
74
75pub fn count_usages_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
76    crate::graph::code_graph::count_usages_query(project_id, symbol_id)
77}
78
79pub fn find_callers_query(
80    project_id: &str,
81    symbol_id: &str,
82    offset: usize,
83    limit: usize,
84) -> (String, HashMap<String, String>) {
85    crate::graph::code_graph::find_callers_query(project_id, symbol_id, offset, limit)
86}
87
88pub fn find_usages_query(
89    project_id: &str,
90    symbol_id: &str,
91    offset: usize,
92    limit: usize,
93) -> (String, HashMap<String, String>) {
94    crate::graph::code_graph::find_usages_query(project_id, symbol_id, offset, limit)
95}
96
97pub fn find_callers_batch_query(
98    project_id: &str,
99    symbol_ids: &[String],
100    limit: usize,
101) -> (String, HashMap<String, String>) {
102    crate::graph::code_graph::find_callers_batch_query(project_id, symbol_ids, limit)
103}
104
105pub fn find_callees_batch_query(
106    project_id: &str,
107    symbol_ids: &[String],
108    limit: usize,
109) -> (String, HashMap<String, String>) {
110    crate::graph::code_graph::find_callees_batch_query(project_id, symbol_ids, limit)
111}
112
113pub fn get_imports_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
114    crate::graph::code_graph::get_imports_query(project_id, file_path)
115}
116
117pub fn blast_radius_query(depth: usize, limit: usize) -> String {
118    crate::graph::code_graph::blast_radius_query(depth, limit)
119}
120
121pub fn with_falkor<T>(
122    ctx: &Context,
123    default: T,
124    f: impl FnOnce(&mut FalkorClient) -> anyhow::Result<T>,
125) -> anyhow::Result<T> {
126    let Some(config) = &ctx.falkordb else {
127        return Ok(default);
128    };
129
130    let mut client = match FalkorClient::from_config(config) {
131        Ok(client) => client,
132        Err(e) => {
133            if !ctx.quiet {
134                eprintln!("Warning: FalkorDB connection failed: {e}");
135            }
136            return Ok(default);
137        }
138    };
139
140    match f(&mut client) {
141        Ok(value) => Ok(value),
142        Err(e) => {
143            if !ctx.quiet {
144                eprintln!("Warning: FalkorDB query failed: {e}");
145            }
146            Ok(default)
147        }
148    }
149}
150
151/// Count callers of a symbol.
152pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
153    crate::graph::code_graph::count_callers(ctx, symbol_id)
154}
155
156/// Count incoming call usages of a symbol.
157pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
158    crate::graph::code_graph::count_usages(ctx, symbol_id)
159}
160
161/// Find symbols that call the given symbol id.
162pub fn find_callers(
163    ctx: &Context,
164    symbol_id: &str,
165    offset: usize,
166    limit: usize,
167) -> anyhow::Result<Vec<GraphResult>> {
168    crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)
169}
170
171/// Find incoming CALLS usages for a canonical, unresolved, or external target.
172pub fn find_usages(
173    ctx: &Context,
174    symbol_id: &str,
175    offset: usize,
176    limit: usize,
177) -> anyhow::Result<Vec<GraphResult>> {
178    crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)
179}
180
181/// Find symbols that call any of the given target ids.
182pub fn find_callers_batch(
183    ctx: &Context,
184    symbol_ids: &[String],
185    limit: usize,
186) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
187    let mut grouped = HashMap::new();
188    for symbol_id in symbol_ids {
189        grouped.insert(
190            symbol_id.clone(),
191            crate::graph::code_graph::find_callers(ctx, symbol_id, 0, limit)?,
192        );
193    }
194    Ok(grouped)
195}
196
197/// Find call targets reached by any of the given source ids.
198pub fn find_callees_batch(
199    ctx: &Context,
200    symbol_ids: &[String],
201    limit: usize,
202) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
203    let mut grouped = HashMap::new();
204    for symbol_id in symbol_ids {
205        grouped.insert(
206            symbol_id.clone(),
207            crate::graph::code_graph::find_callees_batch(
208                ctx,
209                std::slice::from_ref(symbol_id),
210                limit,
211            )?,
212        );
213    }
214    Ok(grouped)
215}
216
217/// Get import graph for a file.
218pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
219    crate::graph::code_graph::get_imports(ctx, file_path)
220}
221
222/// Find transitive blast radius of changing a symbol.
223pub fn blast_radius(
224    ctx: &Context,
225    symbol_id: &str,
226    depth: usize,
227) -> anyhow::Result<Vec<GraphResult>> {
228    crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)
229}
230
231#[cfg(test)]
232fn row_to_graph_result(row: &Row) -> GraphResult {
233    crate::graph::code_graph::row_to_graph_result(row)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use serde_json::json;
240
241    const CALL_TARGET_FRAGMENT: &str =
242        "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
243
244    fn assert_no_numeric_or_list_placeholders(query: &str) {
245        assert!(!query.contains("$offset"), "{query}");
246        assert!(!query.contains("$limit"), "{query}");
247        assert!(!query.contains("$ids"), "{query}");
248    }
249
250    #[test]
251    fn cypher_string_literal_escapes_single_quotes_and_backslashes() {
252        assert_eq!(
253            cypher_string_literal("module\\path'symbol"),
254            "'module\\\\path\\'symbol'"
255        );
256    }
257
258    #[test]
259    fn find_callers_query_interpolates_numeric_skip_and_limit() {
260        let (query, params) = find_callers_query("project-1", "symbol-1", 250, 0);
261
262        assert!(query.contains("SKIP 100 LIMIT 1"), "{query}");
263        assert_no_numeric_or_list_placeholders(&query);
264        assert_eq!(
265            params.get("project").map(String::as_str),
266            Some("'project-1'")
267        );
268        assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
269    }
270
271    #[test]
272    fn find_usages_query_clamps_numeric_skip_and_limit() {
273        let (query, params) = find_usages_query("project-1", "symbol-1", 250, 250);
274
275        assert!(query.contains("SKIP 100 LIMIT 100"), "{query}");
276        assert_no_numeric_or_list_placeholders(&query);
277        assert_eq!(
278            params.get("project").map(String::as_str),
279            Some("'project-1'")
280        );
281        assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
282    }
283
284    #[test]
285    fn batch_query_uses_one_interpolated_in_list() {
286        let (query, params) =
287            find_callers_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
288
289        assert_eq!(query.matches(" IN [").count(), 1, "{query}");
290        assert!(query.contains("target.id IN ['a', 'b\\'\\\\c']"), "{query}");
291        assert!(
292            query.contains("WITH caller, min(r.file) AS file, min(r.line) AS line"),
293            "{query}"
294        );
295        assert!(query.contains("ORDER BY caller.id"), "{query}");
296        assert!(query.contains("LIMIT 100"), "{query}");
297        assert_no_numeric_or_list_placeholders(&query);
298        assert_eq!(
299            params.get("project").map(String::as_str),
300            Some("'project-1'")
301        );
302
303        let (callee_query, callee_params) =
304            find_callees_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
305
306        assert_eq!(callee_query.matches(" IN [").count(), 1, "{callee_query}");
307        assert!(
308            callee_query.contains("src.id IN ['a', 'b\\'\\\\c']"),
309            "{callee_query}"
310        );
311        assert!(
312            callee_query.contains("WITH target, min(r.file) AS file, min(r.line) AS line"),
313            "{callee_query}"
314        );
315        assert!(
316            callee_query.contains("ORDER BY target.id"),
317            "{callee_query}"
318        );
319        assert!(callee_query.contains("LIMIT 100"), "{callee_query}");
320        assert_no_numeric_or_list_placeholders(&callee_query);
321        assert_eq!(
322            callee_params.get("project").map(String::as_str),
323            Some("'project-1'")
324        );
325    }
326
327    #[test]
328    fn blast_radius_query_clamps_depth_and_interpolates_limit() {
329        let query = blast_radius_query(99, 250);
330
331        assert!(query.contains(CALL_TARGET_FRAGMENT), "{query}");
332        assert!(query.contains("[:CALLS*1..5]"), "{query}");
333        assert!(query.contains("LIMIT 100"), "{query}");
334        assert_no_numeric_or_list_placeholders(&query);
335    }
336
337    #[test]
338    fn row_to_graph_result_prefers_blast_radius_node_fields() {
339        let row = Row::from([
340            ("node_id".to_string(), json!("sym-1")),
341            ("node_name".to_string(), json!("foo")),
342            ("file_path".to_string(), json!("src/main.py")),
343            ("line".to_string(), json!(42)),
344            ("rel_type".to_string(), json!("call")),
345            ("distance".to_string(), json!(2)),
346        ]);
347
348        let result = row_to_graph_result(&row);
349
350        assert_eq!(result.id, "sym-1");
351        assert_eq!(result.name, "foo");
352        assert_eq!(result.file_path, "src/main.py");
353        assert_eq!(result.line, 42);
354        assert_eq!(result.relation.as_deref(), Some("call"));
355        assert_eq!(result.distance, Some(2));
356    }
357
358    #[test]
359    fn phase7_query_helpers_preserve_safe_literals_clamping_and_project_scope() {
360        let project_id = "project\n'one";
361        let symbol_id = "symbol\"\\'two";
362        let expected_project = cypher_string_literal(project_id);
363        let expected_symbol = cypher_string_literal(symbol_id);
364
365        let (callers, caller_params) = find_callers_query(project_id, symbol_id, 250, 0);
366        assert!(callers.contains(CALL_TARGET_FRAGMENT), "{callers}");
367        assert!(callers.contains("SKIP 100 LIMIT 1"), "{callers}");
368        assert_no_numeric_or_list_placeholders(&callers);
369        assert_eq!(caller_params.get("project"), Some(&expected_project));
370        assert_eq!(caller_params.get("id"), Some(&expected_symbol));
371
372        let (usages, usage_params) = find_usages_query(project_id, symbol_id, 250, 250);
373        assert!(usages.contains(CALL_TARGET_FRAGMENT), "{usages}");
374        assert!(usages.contains("SKIP 100 LIMIT 100"), "{usages}");
375        assert_no_numeric_or_list_placeholders(&usages);
376        assert_eq!(usage_params.get("project"), Some(&expected_project));
377        assert_eq!(usage_params.get("id"), Some(&expected_symbol));
378
379        let ids = ["a".to_string(), "b\n\"'\\c".to_string()];
380        let expected_ids = id_list_literal(&ids);
381        let (batch, batch_params) = find_callers_batch_query(project_id, &ids, 250);
382        assert!(
383            batch.contains(&format!("target.id IN [{expected_ids}]")),
384            "{batch}"
385        );
386        assert!(
387            batch.contains("WITH caller, min(r.file) AS file, min(r.line) AS line"),
388            "{batch}"
389        );
390        assert!(batch.contains("ORDER BY caller.id"), "{batch}");
391        assert!(batch.contains("LIMIT 100"), "{batch}");
392        assert_no_numeric_or_list_placeholders(&batch);
393        assert_eq!(batch_params.get("project"), Some(&expected_project));
394
395        let blast = blast_radius_query(99, 250);
396        assert!(blast.contains(CALL_TARGET_FRAGMENT), "{blast}");
397        assert!(blast.contains("[:CALLS*1..5]"), "{blast}");
398        assert!(blast.contains("LIMIT 100"), "{blast}");
399        assert_no_numeric_or_list_placeholders(&blast);
400    }
401}