sqlite_graphrag/commands/
prune_relations.rs1use 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 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
24 pub relation: String,
25 #[arg(long)]
26 pub namespace: Option<String>,
27 #[arg(long)]
29 pub dry_run: bool,
30 #[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 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 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 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}