Skip to main content

sqlite_graphrag/commands/graph_export/
handlers.rs

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