Skip to main content

sqlite_graphrag/commands/graph_export/
handlers.rs

1//! Handlers for `graph` subcommands.
2
3use super::args::*;
4use super::formats::{EdgeOut, GraphSnapshot, NodeOut, render_dot, render_json, render_mermaid, render_ndjson_streaming};
5use crate::cli::GraphExportFormat;
6use crate::errors::AppError;
7use crate::output;
8use crate::paths::AppPaths;
9use crate::storage::connection::open_ro;
10use crate::storage::entities;
11use serde::Serialize;
12use std::collections::HashMap;
13use std::fs;
14use std::time::Instant;
15
16/// Dispatch `graph` subcommands (snapshot, traverse, stats, entities, recompute-degree).
17pub fn run(args: GraphArgs) -> Result<(), AppError> {
18    match args.subcommand {
19        None => run_entities_snapshot(
20            args.db.as_deref(),
21            args.namespace.as_deref(),
22            args.format,
23            args.json,
24            args.output.as_deref(),
25        ),
26        Some(GraphSubcommand::Traverse(mut a)) => {
27            if a.db.is_none() {
28                a.db = args.db;
29            }
30            if a.namespace.is_none() {
31                a.namespace = args.namespace;
32            }
33            run_traverse(a)
34        }
35        Some(GraphSubcommand::Stats(mut a)) => {
36            if a.db.is_none() {
37                a.db = args.db;
38            }
39            if a.namespace.is_none() {
40                a.namespace = args.namespace;
41            }
42            run_stats(a)
43        }
44        Some(GraphSubcommand::Entities(mut a)) => {
45            if a.db.is_none() {
46                a.db = args.db;
47            }
48            if a.namespace.is_none() {
49                a.namespace = args.namespace;
50            }
51            run_entities(a)
52        }
53        Some(GraphSubcommand::RecomputeDegree(mut a)) => {
54            if a.db.is_none() {
55                a.db = args.db;
56            }
57            if a.namespace.is_none() {
58                a.namespace = args.namespace;
59            }
60            run_recompute_degree(a)
61        }
62    }
63}
64
65/// v1.1.1 (P3): summary of one degree-reconciliation pass.
66///
67/// `total` is every entity scanned; `updated` diverged to a non-zero real
68/// degree; `zeroed` diverged to zero (no live edges); `unchanged` already
69/// matched. `updated + zeroed + unchanged == total`.
70#[derive(Debug, Serialize, PartialEq, Eq)]
71pub(crate) struct RecomputeDegreeSummary {
72    pub(crate) total: i64,
73    pub(crate) updated: i64,
74    pub(crate) zeroed: i64,
75    pub(crate) unchanged: i64,
76}
77
78#[derive(Serialize)]
79struct RecomputeDegreeResponse {
80    namespace: Option<String>,
81    dry_run: bool,
82    total: i64,
83    updated: i64,
84    zeroed: i64,
85    unchanged: i64,
86    elapsed_ms: u64,
87}
88
89/// v1.1.1 (P3): recomputes `entities.degree` from the real `relationships`
90/// rows inside one IMMEDIATE transaction.
91///
92/// Uses the SAME per-entity semantics as the canonical
93/// [`entities::recalculate_degree`] helper (`COUNT(*) WHERE source_id = id OR
94/// target_id = id` — a self-loop counts once), so a reconciled graph is
95/// byte-identical to one maintained exclusively through link/merge/delete.
96/// With `dry_run` the transaction never writes and is rolled back on drop.
97pub(crate) fn recompute_degrees(
98    conn: &mut rusqlite::Connection,
99    namespace: Option<&str>,
100    dry_run: bool,
101) -> Result<RecomputeDegreeSummary, AppError> {
102    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
103
104    const SELECT_BASE: &str = "SELECT e.id, e.degree, \
105         (SELECT COUNT(*) FROM relationships r \
106          WHERE r.source_id = e.id OR r.target_id = e.id) \
107         FROM entities e";
108    let rows: Vec<(i64, i64, i64)> = if let Some(ns) = namespace {
109        let mut stmt = tx.prepare(&format!("{SELECT_BASE} WHERE e.namespace = ?1"))?;
110        let r = stmt
111            .query_map(rusqlite::params![ns], |r| {
112                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
113            })?
114            .collect::<Result<Vec<_>, _>>()?;
115        r
116    } else {
117        let mut stmt = tx.prepare(SELECT_BASE)?;
118        let r = stmt
119            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
120            .collect::<Result<Vec<_>, _>>()?;
121        r
122    };
123
124    let mut summary = RecomputeDegreeSummary {
125        total: rows.len() as i64,
126        updated: 0,
127        zeroed: 0,
128        unchanged: 0,
129    };
130    for (id, stored, real) in rows {
131        if stored == real {
132            summary.unchanged += 1;
133            continue;
134        }
135        if !dry_run {
136            tx.execute(
137                "UPDATE entities SET degree = ?1, updated_at = unixepoch() WHERE id = ?2",
138                rusqlite::params![real, id],
139            )?;
140        }
141        if real == 0 {
142            summary.zeroed += 1;
143        } else {
144            summary.updated += 1;
145        }
146    }
147
148    if dry_run {
149        // Dropping the transaction rolls back; nothing was written anyway.
150        drop(tx);
151    } else {
152        tx.commit()?;
153    }
154    Ok(summary)
155}
156
157pub(crate) fn run_recompute_degree(args: GraphRecomputeDegreeArgs) -> Result<(), AppError> {
158    let inicio = Instant::now();
159    let paths = AppPaths::resolve(args.db.as_deref())?;
160    crate::storage::connection::ensure_db_ready(&paths)?;
161    let mut conn = crate::storage::connection::open_rw(&paths.db)?;
162
163    let summary = recompute_degrees(&mut conn, args.namespace.as_deref(), args.dry_run)?;
164
165    output::emit_json(&RecomputeDegreeResponse {
166        namespace: args.namespace,
167        dry_run: args.dry_run,
168        total: summary.total,
169        updated: summary.updated,
170        zeroed: summary.zeroed,
171        unchanged: summary.unchanged,
172        elapsed_ms: inicio.elapsed().as_millis() as u64,
173    })?;
174    Ok(())
175}
176
177pub(crate) fn run_entities_snapshot(
178    db: Option<&str>,
179    namespace: Option<&str>,
180    format: GraphExportFormat,
181    json: bool,
182    output_path: Option<&std::path::Path>,
183) -> Result<(), AppError> {
184    let inicio = Instant::now();
185    let paths = AppPaths::resolve(db)?;
186
187    crate::storage::connection::ensure_db_ready(&paths)?;
188
189    let conn = open_ro(&paths.db)?;
190
191    let nodes_raw = entities::list_entities(&conn, namespace)?;
192    let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
193
194    let id_to_name: HashMap<i64, String> =
195        nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
196
197    let nodes: Vec<NodeOut> = nodes_raw
198        .into_iter()
199        .map(|n| NodeOut {
200            id: n.id,
201            name: n.name,
202            namespace: n.namespace,
203            r#type: n.kind.clone(),
204            kind: n.kind,
205        })
206        .collect();
207
208    let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
209    let mut orphan_edges: usize = 0;
210    for r in edges_raw {
211        let from = match id_to_name.get(&r.source_id) {
212            Some(n) => n.clone(),
213            None => {
214                orphan_edges += 1;
215                tracing::warn!(target: "graph_export", source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
216                continue;
217            }
218        };
219        let to = match id_to_name.get(&r.target_id) {
220            Some(n) => n.clone(),
221            None => {
222                orphan_edges += 1;
223                tracing::warn!(target: "graph_export", target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
224                continue;
225            }
226        };
227        edges.push(EdgeOut {
228            from,
229            to,
230            relation: r.relation,
231            weight: r.weight,
232        });
233    }
234    if orphan_edges > 0 {
235        tracing::warn!(target: "graph_export",
236            count = orphan_edges,
237            "edges skipped due to orphaned entity references"
238        );
239    }
240
241    let effective_format = if json {
242        GraphExportFormat::Json
243    } else {
244        format
245    };
246
247    if effective_format == GraphExportFormat::Ndjson {
248        let elapsed_ms = inicio.elapsed().as_millis() as u64;
249        render_ndjson_streaming(&nodes, &edges, elapsed_ms, output_path)?;
250        return Ok(());
251    }
252
253    let rendered = match effective_format {
254        GraphExportFormat::Json => {
255            let entities = nodes.clone();
256            render_json(&GraphSnapshot {
257                nodes,
258                entities,
259                edges,
260                elapsed_ms: inicio.elapsed().as_millis() as u64,
261            })?
262        }
263        GraphExportFormat::Dot => render_dot(&nodes, &edges),
264        GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
265        GraphExportFormat::Ndjson => unreachable!("ndjson handled above"),
266    };
267
268    if let Some(path) = output_path.filter(|_| !json) {
269        fs::write(path, &rendered)?;
270        output::emit_progress(&format!("wrote {}", path.display()));
271    } else {
272        output::emit_text(&rendered);
273    }
274
275    Ok(())
276}
277
278pub(crate) fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
279    let inicio = Instant::now();
280    let _ = args.format;
281    let paths = AppPaths::resolve(args.db.as_deref())?;
282
283    crate::storage::connection::ensure_db_ready(&paths)?;
284
285    let conn = open_ro(&paths.db)?;
286    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
287
288    // v1.1.05 Bug 3: exact match first; with --fuzzy auto-resolve clear
289    // nickname/prefix hits; without it, NotFound includes ranked suggestions.
290    let (from_id, resolved_name) =
291        match entities::resolve_entity_fuzzy(&conn, &namespace, &args.from, args.fuzzy)? {
292            Some((id, name, was_fuzzy)) => {
293                if was_fuzzy {
294                    tracing::warn!(
295                        target: "graph_export",
296                        query = %args.from,
297                        resolved = %name,
298                        "traverse: fuzzy-resolved entity name"
299                    );
300                }
301                (id, name)
302            }
303            None => {
304                return Err(entities::entity_not_found_with_suggestions(
305                    &conn, &namespace, &args.from,
306                ));
307            }
308        };
309
310    let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
311    let all_entities = entities::list_entities(&conn, Some(&namespace))?;
312    let id_to_name: HashMap<i64, String> = all_entities
313        .iter()
314        .map(|e| (e.id, e.name.clone()))
315        .collect();
316
317    let mut hops: Vec<TraverseHop> = Vec::with_capacity(16);
318    let mut visited: std::collections::HashSet<i64> =
319        std::collections::HashSet::with_capacity(args.depth as usize * 10);
320    let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
321
322    while let Some((current_id, current_depth)) = frontier.pop() {
323        if current_depth >= args.depth || visited.contains(&current_id) {
324            continue;
325        }
326        visited.insert(current_id);
327
328        for rel in &all_rels {
329            if rel.source_id == current_id {
330                if let Some(target_name) = id_to_name.get(&rel.target_id) {
331                    hops.push(TraverseHop {
332                        entity: target_name.clone(),
333                        relation: rel.relation.clone(),
334                        direction: "outbound".to_string(),
335                        weight: rel.weight,
336                        depth: current_depth + 1,
337                    });
338                    frontier.push((rel.target_id, current_depth + 1));
339                }
340            } else if rel.target_id == current_id {
341                if let Some(source_name) = id_to_name.get(&rel.source_id) {
342                    hops.push(TraverseHop {
343                        entity: source_name.clone(),
344                        relation: rel.relation.clone(),
345                        direction: "inbound".to_string(),
346                        weight: rel.weight,
347                        depth: current_depth + 1,
348                    });
349                    frontier.push((rel.source_id, current_depth + 1));
350                }
351            }
352        }
353    }
354
355    output::emit_json(&GraphTraverseResponse {
356        from: resolved_name,
357        namespace,
358        depth: args.depth,
359        hops,
360        elapsed_ms: inicio.elapsed().as_millis() as u64,
361    })?;
362
363    Ok(())
364}
365
366pub(crate) fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
367    let inicio = Instant::now();
368    let paths = AppPaths::resolve(args.db.as_deref())?;
369
370    crate::storage::connection::ensure_db_ready(&paths)?;
371
372    let conn = open_ro(&paths.db)?;
373    let ns = args.namespace.as_deref();
374
375    let node_count: i64 = if let Some(n) = ns {
376        conn.query_row(
377            "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
378            rusqlite::params![n],
379            |r| r.get(0),
380        )?
381    } else {
382        conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
383    };
384
385    let edge_count: i64 = if let Some(n) = ns {
386        conn.query_row(
387            "SELECT COUNT(*) FROM relationships r
388             JOIN entities s ON s.id = r.source_id
389             WHERE s.namespace = ?1",
390            rusqlite::params![n],
391            |r| r.get(0),
392        )?
393    } else {
394        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
395    };
396
397    let max_degree: i64 = if let Some(n) = ns {
398        conn.query_row(
399            "SELECT COALESCE(MAX(degree), 0) FROM entities WHERE namespace = ?1",
400            rusqlite::params![n],
401            |r| r.get(0),
402        )?
403    } else {
404        conn.query_row("SELECT COALESCE(MAX(degree), 0) FROM entities", [], |r| {
405            r.get(0)
406        })?
407    };
408
409    // avg_degree = 2 * edge_count / node_count (each edge contributes 2 to total degree sum).
410    let avg_degree = if node_count > 0 {
411        2.0 * (edge_count as f64) / (node_count as f64)
412    } else {
413        0.0
414    };
415
416    let resp = GraphStatsResponse {
417        namespace: args.namespace,
418        node_count,
419        edge_count,
420        avg_degree,
421        max_degree,
422        elapsed_ms: inicio.elapsed().as_millis() as u64,
423    };
424
425    let effective_format = if args.json {
426        GraphStatsFormat::Json
427    } else {
428        args.format
429    };
430
431    match effective_format {
432        GraphStatsFormat::Json => output::emit_json(&resp)?,
433        GraphStatsFormat::Text => {
434            output::emit_text(&format!(
435                "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
436                resp.node_count,
437                resp.edge_count,
438                resp.avg_degree,
439                resp.max_degree,
440                resp.namespace.as_deref().unwrap_or("all"),
441            ));
442        }
443    }
444
445    Ok(())
446}
447
448/// Builds the `ORDER BY` clause fragment from sort options.
449///
450/// Returns a static SQL fragment such as `ORDER BY e.name ASC`.
451pub(crate) fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
452    // The combinations are enumerated as static strings to avoid
453    // format!() allocations in the hot path and satisfy the borrow checker
454    // when the string is used inside conn.prepare().
455    match (sort_by, order) {
456        (None, SortOrder::Asc) | (Some(EntitySortField::Name), SortOrder::Asc) => {
457            "ORDER BY e.name ASC"
458        }
459        (Some(EntitySortField::Name), SortOrder::Desc) => "ORDER BY e.name DESC",
460        (Some(EntitySortField::Degree), SortOrder::Asc) => "ORDER BY degree ASC",
461        (Some(EntitySortField::Degree), SortOrder::Desc) => "ORDER BY degree DESC",
462        (Some(EntitySortField::CreatedAt), SortOrder::Asc) => "ORDER BY e.created_at ASC",
463        (Some(EntitySortField::CreatedAt), SortOrder::Desc) => "ORDER BY e.created_at DESC",
464        // Fallback: None/Desc → sort by name desc (consistent with dir variable).
465        (None, SortOrder::Desc) => "ORDER BY e.name DESC",
466    }
467}
468
469pub(crate) fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
470    let inicio = Instant::now();
471    let paths = AppPaths::resolve(args.db.as_deref())?;
472
473    crate::storage::connection::ensure_db_ready(&paths)?;
474
475    let conn = open_ro(&paths.db)?;
476
477    let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
478        let ts: i64 = r.get(4)?;
479        let created_at = chrono::DateTime::from_timestamp(ts, 0)
480            .unwrap_or_default()
481            .format("%Y-%m-%dT%H:%M:%SZ")
482            .to_string();
483        Ok(EntityItem {
484            id: r.get(0)?,
485            name: r.get(1)?,
486            entity_type: r.get(2)?,
487            namespace: r.get(3)?,
488            created_at,
489            degree: r.get(5)?,
490            description: r.get(6)?,
491        })
492    };
493
494    let limit_i = args.limit as i64;
495    let offset_i = args.offset as i64;
496    let order_clause = build_order_by(args.sort_by, args.order);
497
498    let base_select = "SELECT e.id, e.name, COALESCE(e.type, ''), e.namespace, e.created_at,
499                        (SELECT COUNT(*) FROM relationships r
500                         WHERE r.source_id = e.id OR r.target_id = e.id) AS degree,
501                        e.description
502                 FROM entities e";
503
504    let (total_count, items) = match (
505        args.namespace.as_deref(),
506        args.entity_type.map(|et| et.as_str()),
507    ) {
508        (Some(ns), Some(et)) => {
509            let count: i64 = conn.query_row(
510                "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
511                rusqlite::params![ns, et],
512                |r| r.get(0),
513            )?;
514            let sql = format!(
515                "{base_select} WHERE e.namespace = ?1 AND e.type = ?2 {order_clause} LIMIT ?3 OFFSET ?4"
516            );
517            let mut stmt = conn.prepare(&sql)?;
518            let rows = stmt
519                .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
520                .collect::<rusqlite::Result<Vec<_>>>()?;
521            (count, rows)
522        }
523        (Some(ns), None) => {
524            let count: i64 = conn.query_row(
525                "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
526                rusqlite::params![ns],
527                |r| r.get(0),
528            )?;
529            let sql =
530                format!("{base_select} WHERE e.namespace = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
531            let mut stmt = conn.prepare(&sql)?;
532            let rows = stmt
533                .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
534                .collect::<rusqlite::Result<Vec<_>>>()?;
535            (count, rows)
536        }
537        (None, Some(et)) => {
538            let count: i64 = conn.query_row(
539                "SELECT COUNT(*) FROM entities WHERE type = ?1",
540                rusqlite::params![et],
541                |r| r.get(0),
542            )?;
543            let sql = format!("{base_select} WHERE e.type = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
544            let mut stmt = conn.prepare(&sql)?;
545            let rows = stmt
546                .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
547                .collect::<rusqlite::Result<Vec<_>>>()?;
548            (count, rows)
549        }
550        (None, None) => {
551            let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
552            let sql = format!("{base_select} {order_clause} LIMIT ?1 OFFSET ?2");
553            let mut stmt = conn.prepare(&sql)?;
554            let rows = stmt
555                .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
556                .collect::<rusqlite::Result<Vec<_>>>()?;
557            (count, rows)
558        }
559    };
560
561    output::emit_json(&GraphEntitiesResponse {
562        entities: items,
563        total_count,
564        limit: args.limit,
565        offset: args.offset,
566        namespace: args.namespace,
567        elapsed_ms: inicio.elapsed().as_millis() as u64,
568    })
569}