sqlite_graphrag/commands/
cleanup_orphans.rs1use crate::errors::AppError;
4use crate::output::{self, OutputFormat};
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use crate::storage::entities;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n \
12 # Remove orphan entities (no memories, no relationships) from the global namespace\n \
13 sqlite-graphrag cleanup-orphans\n\n \
14 # Preview which entities would be removed without deleting\n \
15 sqlite-graphrag cleanup-orphans --dry-run\n\n \
16 # Cleanup within a specific namespace\n \
17 sqlite-graphrag cleanup-orphans --namespace my-project --yes")]
18pub struct CleanupOrphansArgs {
20 #[arg(long)]
22 pub namespace: Option<String>,
23 #[arg(long)]
25 pub dry_run: bool,
26 #[arg(long)]
28 pub yes: bool,
29 #[arg(long, value_enum, default_value = "json")]
31 pub format: OutputFormat,
32 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
34 pub json: bool,
35 #[arg(long)]
37 pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct CleanupResponse {
42 orphan_count: usize,
43 deleted: usize,
44 dangling_relationship_count: usize,
51 dangling_relationships_deleted: usize,
52 foreign_key_violation_count: usize,
60 foreign_key_violations_remaining: usize,
68 dry_run: bool,
69 namespace: Option<String>,
70 elapsed_ms: u64,
72}
73
74pub fn run(args: CleanupOrphansArgs) -> Result<(), AppError> {
76 let started = std::time::Instant::now();
77 let paths = AppPaths::resolve(args.db.as_deref())?;
78
79 crate::storage::connection::ensure_db_ready(&paths)?;
80
81 let mut conn = open_rw(&paths.db)?;
82
83 let orphan_ids = entities::find_orphan_entity_ids(&conn, args.namespace.as_deref())?;
84 let orphan_count = orphan_ids.len();
85
86 let dangling_ids = entities::find_dangling_relationship_ids(&conn, args.namespace.as_deref())?;
90 let dangling_relationship_count = dangling_ids.len();
91
92 let violations = crate::storage::foreign_keys::find_foreign_key_violations(&conn)?;
96 let foreign_key_violation_count = violations.len();
97
98 let (deleted, dangling_relationships_deleted) = if args.dry_run {
99 (0, 0)
100 } else {
101 let total = orphan_count + dangling_relationship_count + foreign_key_violation_count;
102 if total > 0 && !args.yes {
103 return Err(AppError::Validation(
104 crate::i18n::validation::refuse_delete_orphans_without_yes(total),
105 ));
106 }
107 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
108 let edges_removed = entities::delete_relationships_by_ids(&tx, &dangling_ids)?;
113 let removed = entities::delete_entities_by_ids(&tx, &orphan_ids)?;
114 if args.namespace.is_none() {
118 let left = crate::storage::foreign_keys::find_foreign_key_violations(&tx)?;
122 crate::storage::foreign_keys::delete_foreign_key_violations(&tx, &left)?;
123 }
124 tx.commit()?;
125 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
126 (removed, edges_removed)
127 };
128
129 let foreign_key_violations_remaining = if args.dry_run {
138 foreign_key_violation_count
139 } else {
140 crate::storage::foreign_keys::find_foreign_key_violations(&conn)?.len()
141 };
142
143 let response = CleanupResponse {
144 orphan_count,
145 deleted,
146 dangling_relationship_count,
147 dangling_relationships_deleted,
148 foreign_key_violation_count,
149 foreign_key_violations_remaining,
150 dry_run: args.dry_run,
151 namespace: args.namespace.clone(),
152 elapsed_ms: started.elapsed().as_millis() as u64,
153 };
154
155 match args.format {
156 OutputFormat::Json => output::emit_json(&response)?,
157 OutputFormat::Text | OutputFormat::Markdown => {
158 let ns = response.namespace.as_deref().unwrap_or("<all>");
159 output::emit_text(&format!(
160 "orphans: {} entities found, {} deleted; {} dangling relationships found, {} deleted; \
161 {} foreign key violations found, {} still remaining (dry_run={}) [{}]",
162 response.orphan_count,
163 response.deleted,
164 response.dangling_relationship_count,
165 response.dangling_relationships_deleted,
166 response.foreign_key_violation_count,
167 response.foreign_key_violations_remaining,
168 response.dry_run,
169 ns
170 ));
171 }
172 }
173
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn cleanup_response_serializes_dry_run_true() {
183 let resp = CleanupResponse {
184 orphan_count: 5,
185 deleted: 0,
186 dangling_relationship_count: 2,
187 dangling_relationships_deleted: 0,
188 foreign_key_violation_count: 2,
189 foreign_key_violations_remaining: 2,
192 dry_run: true,
193 namespace: Some("global".to_string()),
194 elapsed_ms: 12,
195 };
196 let json = serde_json::to_value(&resp).expect("serialization failed");
197 assert_eq!(json["orphan_count"], 5);
198 assert_eq!(json["deleted"], 0);
199 assert_eq!(json["dry_run"], true);
200 assert_eq!(json["namespace"], "global");
201 assert!(json["elapsed_ms"].is_number());
202 }
203
204 #[test]
205 fn cleanup_response_deleted_zero_when_dry_run() {
206 let resp = CleanupResponse {
207 orphan_count: 10,
208 deleted: 0,
209 dangling_relationship_count: 0,
210 dangling_relationships_deleted: 0,
211 foreign_key_violation_count: 0,
212 foreign_key_violations_remaining: 0,
213 dry_run: true,
214 namespace: None,
215 elapsed_ms: 5,
216 };
217 assert_eq!(resp.deleted, 0, "dry_run must keep deleted at 0");
218 assert_eq!(resp.orphan_count, 10);
219 }
220
221 #[test]
222 fn cleanup_response_namespace_none_serializes_null() {
223 let resp = CleanupResponse {
224 orphan_count: 0,
225 deleted: 0,
226 dangling_relationship_count: 0,
227 dangling_relationships_deleted: 0,
228 foreign_key_violation_count: 0,
229 foreign_key_violations_remaining: 0,
230 dry_run: false,
231 namespace: None,
232 elapsed_ms: 1,
233 };
234 let json = serde_json::to_value(&resp).expect("serialization failed");
235 assert!(
236 json["namespace"].is_null(),
237 "namespace None must serialize as null"
238 );
239 }
240
241 #[test]
242 fn cleanup_response_deleted_equals_orphan_count_when_executed() {
243 let resp = CleanupResponse {
244 orphan_count: 3,
245 deleted: 3,
246 dangling_relationship_count: 4,
247 dangling_relationships_deleted: 4,
248 foreign_key_violation_count: 4,
249 foreign_key_violations_remaining: 0,
252 dry_run: false,
253 namespace: Some("projeto".to_string()),
254 elapsed_ms: 20,
255 };
256 assert_eq!(
257 resp.deleted, resp.orphan_count,
258 "when running without dry_run, deleted must equal orphan_count"
259 );
260 }
261}