Skip to main content

sqlite_graphrag/commands/
prune_relations.rs

1//! Handler for the `prune-relations` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n;
5use crate::output::{self, OutputFormat};
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::entities;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Preview how many 'mentions' relations would be removed\n  \
14    sqlite-graphrag prune-relations --relation mentions --dry-run\n\n  \
15    # Remove all 'mentions' relations without confirmation prompt\n  \
16    sqlite-graphrag prune-relations --relation mentions --yes\n\n\
17NOTE:\n  \
18    This command permanently deletes relationships. Use --dry-run first.\n  \
19    Entity degree counts are automatically recalculated after pruning.")]
20pub struct PruneRelationsArgs {
21    /// Relation type to delete (e.g. mentions, related, uses).
22    /// Accepts canonical and custom kebab-case/snake_case values.
23    #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
24    pub relation: String,
25    #[arg(long)]
26    pub namespace: Option<String>,
27    /// Preview count without deleting.
28    #[arg(long)]
29    pub dry_run: bool,
30    /// Skip confirmation for destructive operation.
31    #[arg(long)]
32    pub yes: bool,
33    #[arg(long, value_enum, default_value = "json")]
34    pub format: OutputFormat,
35    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
36    pub json: bool,
37    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
38    pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct PruneRelationsResponse {
43    action: String,
44    relation: String,
45    count: usize,
46    entities_affected: usize,
47    namespace: String,
48    /// Total execution time in milliseconds from handler start to serialisation.
49    elapsed_ms: u64,
50}
51
52pub fn run(args: PruneRelationsArgs) -> Result<(), AppError> {
53    let inicio = std::time::Instant::now();
54    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
55    let paths = AppPaths::resolve(args.db.as_deref())?;
56
57    crate::storage::connection::ensure_db_ready(&paths)?;
58
59    crate::parsers::warn_if_non_canonical(&args.relation);
60
61    let mut conn = open_rw(&paths.db)?;
62
63    if args.dry_run {
64        let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
65
66        output::emit_progress(&i18n::prune_dry_run(count, &args.relation));
67
68        let response = PruneRelationsResponse {
69            action: "dry_run".to_string(),
70            relation: args.relation.clone(),
71            count,
72            entities_affected: 0,
73            namespace: namespace.clone(),
74            elapsed_ms: inicio.elapsed().as_millis() as u64,
75        };
76
77        match args.format {
78            OutputFormat::Json => output::emit_json(&response)?,
79            OutputFormat::Text | OutputFormat::Markdown => {
80                output::emit_text(&format!(
81                    "dry_run: {} '{}' relations would be removed [{}]",
82                    response.count, response.relation, response.namespace
83                ));
84            }
85        }
86
87        return Ok(());
88    }
89
90    if !args.yes {
91        output::emit_progress(&i18n::prune_requires_yes());
92
93        let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
94
95        let response = PruneRelationsResponse {
96            action: "aborted".to_string(),
97            relation: args.relation.clone(),
98            count,
99            entities_affected: 0,
100            namespace: namespace.clone(),
101            elapsed_ms: inicio.elapsed().as_millis() as u64,
102        };
103
104        match args.format {
105            OutputFormat::Json => output::emit_json(&response)?,
106            OutputFormat::Text | OutputFormat::Markdown => {
107                output::emit_text(&format!(
108                    "aborted: {} '{}' relations would be removed; pass --yes to confirm [{}]",
109                    response.count, response.relation, response.namespace
110                ));
111            }
112        }
113
114        return Ok(());
115    }
116
117    // Destructive path: delete relationships.
118    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
119    let (count, entity_ids) =
120        entities::delete_relationships_by_relation(&tx, &namespace, &args.relation)?;
121    tx.commit()?;
122
123    // Run ANALYZE to refresh query planner statistics after bulk deletion.
124    conn.execute_batch("ANALYZE relationships; ANALYZE memory_relationships;")?;
125
126    output::emit_progress(&i18n::relations_pruned(count, &args.relation, &namespace));
127
128    let response = PruneRelationsResponse {
129        action: "pruned".to_string(),
130        relation: args.relation.clone(),
131        count,
132        entities_affected: entity_ids.len(),
133        namespace: namespace.clone(),
134        elapsed_ms: inicio.elapsed().as_millis() as u64,
135    };
136
137    match args.format {
138        OutputFormat::Json => output::emit_json(&response)?,
139        OutputFormat::Text | OutputFormat::Markdown => {
140            output::emit_text(&format!(
141                "pruned: {} '{}' relations removed, {} entities affected [{}]",
142                response.count, response.relation, response.entities_affected, response.namespace
143            ));
144        }
145    }
146
147    Ok(())
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn prune_response_serializes_all_fields() {
156        let resp = PruneRelationsResponse {
157            action: "pruned".to_string(),
158            relation: "mentions".to_string(),
159            count: 3451,
160            entities_affected: 200,
161            namespace: "global".to_string(),
162            elapsed_ms: 42,
163        };
164        let json = serde_json::to_value(&resp).expect("serialization failed");
165        assert_eq!(json["action"], "pruned");
166        assert_eq!(json["relation"], "mentions");
167        assert_eq!(json["count"], 3451);
168        assert_eq!(json["entities_affected"], 200);
169        assert_eq!(json["namespace"], "global");
170        assert!(json["elapsed_ms"].is_number());
171    }
172
173    #[test]
174    fn prune_response_action_dry_run() {
175        let resp = PruneRelationsResponse {
176            action: "dry_run".to_string(),
177            relation: "mentions".to_string(),
178            count: 100,
179            entities_affected: 0,
180            namespace: "test".to_string(),
181            elapsed_ms: 5,
182        };
183        let json = serde_json::to_value(&resp).expect("serialization failed");
184        assert_eq!(json["action"], "dry_run");
185        assert_eq!(
186            json["entities_affected"], 0,
187            "dry_run must report zero entities_affected"
188        );
189    }
190
191    #[test]
192    fn prune_response_action_pruned() {
193        let resp = PruneRelationsResponse {
194            action: "pruned".to_string(),
195            relation: "uses".to_string(),
196            count: 50,
197            entities_affected: 10,
198            namespace: "my-project".to_string(),
199            elapsed_ms: 120,
200        };
201        let json = serde_json::to_value(&resp).expect("serialization failed");
202        assert_eq!(json["action"], "pruned");
203        assert!(json["count"].as_u64().unwrap() > 0);
204        assert!(json["entities_affected"].as_u64().unwrap() > 0);
205    }
206
207    #[test]
208    fn prune_response_zero_count_when_nothing_to_prune() {
209        let resp = PruneRelationsResponse {
210            action: "pruned".to_string(),
211            relation: "nonexistent".to_string(),
212            count: 0,
213            entities_affected: 0,
214            namespace: "global".to_string(),
215            elapsed_ms: 1,
216        };
217        let json = serde_json::to_value(&resp).expect("serialization failed");
218        assert_eq!(json["count"], 0);
219        assert_eq!(json["entities_affected"], 0);
220    }
221}