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 {
22 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
25 pub relation: String,
26 #[arg(long)]
28 pub namespace: Option<String>,
29 #[arg(long)]
31 pub dry_run: bool,
32 #[arg(long)]
34 pub yes: bool,
35 #[arg(long, default_value_t = false)]
37 pub show_entities: bool,
38 #[arg(long, value_enum, default_value = "json")]
40 pub format: OutputFormat,
41 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
43 pub json: bool,
44 #[arg(long)]
46 pub db: Option<String>,
47}
48
49#[derive(Serialize)]
50struct PruneRelationsResponse {
51 action: String,
52 relation: String,
53 count: usize,
54 entities_affected: usize,
55 namespace: String,
56 elapsed_ms: u64,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 affected_entity_names: Option<Vec<String>>,
60}
61
62pub fn run(args: PruneRelationsArgs) -> Result<(), AppError> {
64 let inicio = std::time::Instant::now();
65 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
66 let paths = AppPaths::resolve(args.db.as_deref())?;
67
68 crate::storage::connection::ensure_db_ready(&paths)?;
69
70 crate::parsers::warn_if_non_canonical(&args.relation);
71
72 let mut conn = open_rw(&paths.db)?;
73
74 if args.dry_run {
75 let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
76
77 let affected_names = if args.show_entities {
78 Some(entities::list_entity_names_by_relation(
79 &conn,
80 &namespace,
81 &args.relation,
82 )?)
83 } else {
84 None
85 };
86
87 let entities_affected_count = affected_names.as_ref().map_or(0, |v| v.len());
88
89 output::emit_progress(&i18n::prune_dry_run(count, &args.relation));
90
91 let response = PruneRelationsResponse {
92 action: "dry_run".to_string(),
93 relation: args.relation.clone(),
94 count,
95 entities_affected: entities_affected_count,
96 namespace: namespace.clone(),
97 elapsed_ms: inicio.elapsed().as_millis() as u64,
98 affected_entity_names: affected_names,
99 };
100
101 match args.format {
102 OutputFormat::Json => output::emit_json(&response)?,
103 OutputFormat::Text | OutputFormat::Markdown => {
104 output::emit_text(&format!(
105 "dry_run: {} '{}' relations would be removed [{}]",
106 response.count, response.relation, response.namespace
107 ));
108 }
109 }
110
111 return Ok(());
112 }
113
114 if !args.yes {
115 output::emit_progress(&i18n::prune_requires_yes());
116
117 let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
118
119 let response = PruneRelationsResponse {
120 action: "aborted".to_string(),
121 relation: args.relation.clone(),
122 count,
123 entities_affected: 0,
124 namespace: namespace.clone(),
125 elapsed_ms: inicio.elapsed().as_millis() as u64,
126 affected_entity_names: None,
127 };
128
129 match args.format {
130 OutputFormat::Json => output::emit_json(&response)?,
131 OutputFormat::Text | OutputFormat::Markdown => {
132 output::emit_text(&format!(
133 "aborted: {} '{}' relations would be removed; pass --yes to confirm [{}]",
134 response.count, response.relation, response.namespace
135 ));
136 }
137 }
138
139 return Ok(());
140 }
141
142 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
144 let (count, entity_ids) =
145 entities::delete_relationships_by_relation(&tx, &namespace, &args.relation)?;
146 tx.commit()?;
147
148 conn.execute_batch("ANALYZE relationships; ANALYZE memory_relationships;")?;
150 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
151
152 output::emit_progress(&i18n::relations_pruned(count, &args.relation, &namespace));
153
154 let response = PruneRelationsResponse {
155 action: "pruned".to_string(),
156 relation: args.relation.clone(),
157 count,
158 entities_affected: entity_ids.len(),
159 namespace: namespace.clone(),
160 elapsed_ms: inicio.elapsed().as_millis() as u64,
161 affected_entity_names: None,
162 };
163
164 match args.format {
165 OutputFormat::Json => output::emit_json(&response)?,
166 OutputFormat::Text | OutputFormat::Markdown => {
167 output::emit_text(&format!(
168 "pruned: {} '{}' relations removed, {} entities affected [{}]",
169 response.count, response.relation, response.entities_affected, response.namespace
170 ));
171 }
172 }
173
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn prune_response_serializes_all_fields() {
183 let resp = PruneRelationsResponse {
184 action: "pruned".to_string(),
185 relation: "mentions".to_string(),
186 count: 3451,
187 entities_affected: 200,
188 namespace: "global".to_string(),
189 elapsed_ms: 42,
190 affected_entity_names: None,
191 };
192 let json = serde_json::to_value(&resp).expect("serialization failed");
193 assert_eq!(json["action"], "pruned");
194 assert_eq!(json["relation"], "mentions");
195 assert_eq!(json["count"], 3451);
196 assert_eq!(json["entities_affected"], 200);
197 assert_eq!(json["namespace"], "global");
198 assert!(json["elapsed_ms"].is_number());
199 }
200
201 #[test]
202 fn prune_response_action_dry_run() {
203 let resp = PruneRelationsResponse {
204 action: "dry_run".to_string(),
205 relation: "mentions".to_string(),
206 count: 100,
207 entities_affected: 0,
208 namespace: "test".to_string(),
209 elapsed_ms: 5,
210 affected_entity_names: None,
211 };
212 let json = serde_json::to_value(&resp).expect("serialization failed");
213 assert_eq!(json["action"], "dry_run");
214 assert_eq!(
215 json["entities_affected"], 0,
216 "dry_run must report zero entities_affected"
217 );
218 }
219
220 #[test]
221 fn prune_response_action_pruned() {
222 let resp = PruneRelationsResponse {
223 action: "pruned".to_string(),
224 relation: "uses".to_string(),
225 count: 50,
226 entities_affected: 10,
227 namespace: "my-project".to_string(),
228 elapsed_ms: 120,
229 affected_entity_names: None,
230 };
231 let json = serde_json::to_value(&resp).expect("serialization failed");
232 assert_eq!(json["action"], "pruned");
233 assert!(json["count"].as_u64().unwrap() > 0);
234 assert!(json["entities_affected"].as_u64().unwrap() > 0);
235 }
236
237 #[test]
238 fn prune_response_zero_count_when_nothing_to_prune() {
239 let resp = PruneRelationsResponse {
240 action: "pruned".to_string(),
241 relation: "nonexistent".to_string(),
242 count: 0,
243 entities_affected: 0,
244 namespace: "global".to_string(),
245 elapsed_ms: 1,
246 affected_entity_names: None,
247 };
248 let json = serde_json::to_value(&resp).expect("serialization failed");
249 assert_eq!(json["count"], 0);
250 assert_eq!(json["entities_affected"], 0);
251 }
252
253 #[test]
254 fn prune_response_verbose_includes_entity_names() {
255 let resp = PruneRelationsResponse {
256 action: "dry_run".to_string(),
257 relation: "mentions".to_string(),
258 count: 10,
259 entities_affected: 3,
260 namespace: "global".to_string(),
261 elapsed_ms: 5,
262 affected_entity_names: Some(vec!["alpha".into(), "beta".into(), "gamma".into()]),
263 };
264 let json = serde_json::to_value(&resp).expect("serialization failed");
265 let names = json["affected_entity_names"]
266 .as_array()
267 .expect("must be array");
268 assert_eq!(names.len(), 3);
269 }
270
271 #[test]
272 fn prune_response_no_verbose_omits_entity_names() {
273 let resp = PruneRelationsResponse {
274 action: "dry_run".to_string(),
275 relation: "mentions".to_string(),
276 count: 10,
277 entities_affected: 0,
278 namespace: "global".to_string(),
279 elapsed_ms: 5,
280 affected_entity_names: None,
281 };
282 let json = serde_json::to_value(&resp).expect("serialization failed");
283 assert!(
284 json.get("affected_entity_names").is_none(),
285 "must be omitted when None"
286 );
287 }
288
289 #[test]
290 fn prune_response_action_values_are_exhaustive() {
291 for action in &["pruned", "dry_run", "aborted"] {
292 let resp = PruneRelationsResponse {
293 action: action.to_string(),
294 relation: "mentions".to_string(),
295 count: 0,
296 entities_affected: 0,
297 namespace: "global".to_string(),
298 elapsed_ms: 0,
299 affected_entity_names: None,
300 };
301 let json = serde_json::to_value(&resp).expect("serialization");
302 assert_eq!(json["action"], *action);
303 }
304 }
305}