Skip to main content

sqlite_graphrag/commands/
cleanup_orphans.rs

1//! Handler for the `cleanup-orphans` CLI subcommand.
2
3use 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")]
18/// Cleanup orphans args.
19pub struct CleanupOrphansArgs {
20    /// Namespace scope.
21    #[arg(long)]
22    pub namespace: Option<String>,
23    /// Show what would happen without making changes.
24    #[arg(long)]
25    pub dry_run: bool,
26    /// Yes.
27    #[arg(long)]
28    pub yes: bool,
29    /// Output format.
30    #[arg(long, value_enum, default_value = "json")]
31    pub format: OutputFormat,
32    /// Emit machine-readable JSON on stdout.
33    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
34    pub json: bool,
35    /// Path to the SQLite database file.
36    #[arg(long)]
37    pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct CleanupResponse {
42    orphan_count: usize,
43    deleted: usize,
44    /// Relationship rows pointing at an entity id that does not exist.
45    ///
46    /// Reported separately from `orphan_count` because they are the mirror
47    /// image of it — edges with no entity, rather than entities with no edges —
48    /// and because a caller watching one number would otherwise read a repair
49    /// of the other as a no-op.
50    dangling_relationship_count: usize,
51    dangling_relationships_deleted: usize,
52    /// Rows reported by `PRAGMA foreign_key_check` across the whole file,
53    /// measured before anything is deleted.
54    ///
55    /// Wider than `dangling_relationship_count` and overlapping it: eleven
56    /// child tables reference `memories`, `entities`, `relationships` or
57    /// `memory_chunks`, and a schema rebuild orphans several at once. The
58    /// pragma has no notion of namespace, so this number is always whole-file.
59    foreign_key_violation_count: usize,
60    /// The same measurement taken again after the deletes.
61    ///
62    /// Reported instead of a "repaired" tally because it answers the question
63    /// the operator actually has — is the file still broken — rather than
64    /// asserting that the repair worked. Equals the count above under
65    /// `--dry-run`, and stays above zero when `--namespace` scoped the run away
66    /// from violations living elsewhere in the file.
67    foreign_key_violations_remaining: usize,
68    dry_run: bool,
69    namespace: Option<String>,
70    /// Total execution time in milliseconds from handler start to serialisation.
71    elapsed_ms: u64,
72}
73
74/// Run.
75pub 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    // Dangling edges are the state `PRAGMA foreign_key_check` reports on every
87    // migration, and until now this command — the one named for orphans — did
88    // not touch them, so there was no supported repair for it at all.
89    let dangling_ids = entities::find_dangling_relationship_ids(&conn, args.namespace.as_deref())?;
90    let dangling_relationship_count = dangling_ids.len();
91
92    // Whole-file, every child table. The migration guard warns about this exact
93    // set and names this command as the repair, so the command has to be able
94    // to deliver on more than one of the eleven tables involved.
95    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        // Edges first: removing them can leave an entity with no edges, and
109        // that entity is only an orphan by this command's own definition after
110        // the edge is gone. Doing it in the other order would need a second
111        // pass to reach the same state.
112        let edges_removed = entities::delete_relationships_by_ids(&tx, &dangling_ids)?;
113        let removed = entities::delete_entities_by_ids(&tx, &orphan_ids)?;
114        // Last, and only unscoped: the pragma cannot be filtered by namespace,
115        // so a namespaced run reports the wider damage without touching rows
116        // that belong to projects sharing this file.
117        if args.namespace.is_none() {
118            // Re-read inside the transaction: the two deletes above already
119            // removed part of the set, and deleting a rowid twice would report
120            // a repair that did not happen.
121            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    // Measured again rather than inferred: a post-condition that reports what
130    // the code intended, instead of what the file now holds, verifies nothing.
131    //
132    // Skipped under --dry-run, where nothing was written and the answer is the
133    // first measurement by definition. Re-scanning there would spend a second
134    // full pass over every child table to report a number that could only
135    // differ because of somebody else's writes — noise attributed to a preview
136    // that touched nothing.
137    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            // A dry run repairs nothing, so the second measurement must match
190            // the first. Anything else would mean the preview mutated the file.
191            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            // An executed repair must end with the file satisfying its own
250            // foreign keys; a non-zero here is the signal that it did not.
251            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}