Skip to main content

sqlite_graphrag/commands/
delete_entity.rs

1//! Handler for the `delete-entity` CLI subcommand (GAP-17).
2//!
3//! Deletes an entity and, with `--cascade`, all of its relationships and
4//! memory bindings. Without `--cascade` the command refuses to proceed, which
5//! prevents accidental data loss.
6
7use crate::errors::AppError;
8use crate::i18n::errors_msg;
9use crate::output::{self, OutputFormat};
10use crate::paths::AppPaths;
11use crate::storage::connection::open_rw;
12use crate::storage::entities;
13use rusqlite::params;
14use serde::Serialize;
15
16#[derive(clap::Args)]
17#[command(after_long_help = "EXAMPLES:\n  \
18    # Delete an entity and all its relationships (cascade required)\n  \
19    sqlite-graphrag delete-entity --name auth-module --cascade\n\n  \
20    # Delete an entity in a specific namespace\n  \
21    sqlite-graphrag delete-entity --name legacy-service --cascade --namespace my-project\n\n  \
22    # Without --cascade the command exits with an error:\n  \
23    sqlite-graphrag delete-entity --name auth-module\n  \
24    # => Error: use --cascade to confirm deletion of entity and all its relationships\n\n\
25NOTE:\n  \
26    --cascade is required and acts as an explicit confirmation gate.\n  \
27    All relationships where this entity is source or target are removed.\n  \
28    All memory-entity bindings (memory_entities rows) are also removed.\n  \
29    Run `sqlite-graphrag cleanup-orphans` afterwards to remove any newly orphaned entities.")]
30/// Delete entity args.
31pub struct DeleteEntityArgs {
32    /// Entity name to delete (graph node, not memory name).
33    #[arg(long)]
34    pub name: String,
35    /// Required confirmation flag. Without it the command exits with an error.
36    ///
37    /// Deletes all relationships and memory bindings attached to this entity.
38    #[arg(long, default_value_t = false)]
39    pub cascade: bool,
40    /// Namespace scope.
41    #[arg(long)]
42    pub namespace: Option<String>,
43    /// Output format.
44    #[arg(long, value_enum, default_value = "json")]
45    pub format: OutputFormat,
46    /// Emit machine-readable JSON on stdout.
47    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
48    pub json: bool,
49    /// Path to the SQLite database file.
50    #[arg(long)]
51    pub db: Option<String>,
52}
53
54#[derive(Serialize)]
55struct DeleteEntityResponse {
56    action: String,
57    entity_name: String,
58    namespace: String,
59    relationships_removed: usize,
60    bindings_removed: usize,
61    /// Total execution time in milliseconds from handler start to serialisation.
62    elapsed_ms: u64,
63}
64
65/// Run.
66pub fn run(args: DeleteEntityArgs) -> Result<(), AppError> {
67    let inicio = std::time::Instant::now();
68
69    if !args.cascade {
70        return Err(AppError::Validation(
71            "use --cascade to confirm deletion of entity and all its relationships".to_string(),
72        ));
73    }
74
75    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
76    let paths = AppPaths::resolve(args.db.as_deref())?;
77
78    crate::storage::connection::ensure_db_ready(&paths)?;
79
80    let mut conn = open_rw(&paths.db)?;
81
82    let entity_id = entities::find_entity_id(&conn, &namespace, &args.name)?
83        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.name, &namespace)))?;
84
85    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
86
87    // Step 0: collect adjacent entity IDs BEFORE deleting relationships.
88    let adjacent_ids: Vec<i64> = {
89        let mut stmt = tx.prepare(
90            "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
91             FROM relationships WHERE source_id = ?1 OR target_id = ?1",
92        )?;
93        let ids: Vec<i64> = stmt
94            .query_map(params![entity_id], |r| r.get(0))?
95            .collect::<Result<Vec<_>, _>>()?;
96        ids
97    };
98
99    // Step 1: collect relationship IDs for this entity (source or target).
100    let rel_ids: Vec<i64> = {
101        let mut stmt =
102            tx.prepare("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
103        let ids: Vec<i64> = stmt
104            .query_map(params![entity_id], |r| r.get::<_, i64>(0))?
105            .collect::<Result<Vec<_>, _>>()?;
106        ids
107    };
108
109    // Step 2: delete memory_relationships for each collected relationship id.
110    for &rel_id in &rel_ids {
111        tx.execute(
112            "DELETE FROM memory_relationships WHERE relationship_id = ?1",
113            params![rel_id],
114        )?;
115    }
116
117    // Step 3: delete the relationships themselves.
118    let relationships_removed = tx.execute(
119        "DELETE FROM relationships WHERE source_id = ?1 OR target_id = ?1",
120        params![entity_id],
121    )?;
122
123    // Step 4: delete memory_entities bindings.
124    let bindings_removed = tx.execute(
125        "DELETE FROM memory_entities WHERE entity_id = ?1",
126        params![entity_id],
127    )?;
128
129    // Step 5: delete vec_entities row (ignore error — row may not exist).
130    let _ = tx.execute(
131        "DELETE FROM vec_entities WHERE entity_id = ?1",
132        params![entity_id],
133    );
134
135    // Step 6: delete the entity itself.
136    tx.execute("DELETE FROM entities WHERE id = ?1", params![entity_id])?;
137
138    // Step 7: recalculate degree for adjacent entities that lost relationships.
139    for &adj_id in &adjacent_ids {
140        if adj_id != entity_id {
141            entities::recalculate_degree(&tx, adj_id)?;
142        }
143    }
144
145    tx.commit()?;
146
147    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
148
149    let response = DeleteEntityResponse {
150        action: "deleted".to_string(),
151        entity_name: args.name.clone(),
152        namespace: namespace.clone(),
153        relationships_removed,
154        bindings_removed,
155        elapsed_ms: inicio.elapsed().as_millis() as u64,
156    };
157
158    match args.format {
159        OutputFormat::Json => output::emit_json(&response)?,
160        OutputFormat::Text | OutputFormat::Markdown => {
161            output::emit_text(&format!(
162                "deleted: {} (relationships_removed={}, bindings_removed={}) [{}]",
163                response.entity_name,
164                response.relationships_removed,
165                response.bindings_removed,
166                response.namespace
167            ));
168        }
169    }
170
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn delete_entity_response_serializes_all_fields() {
180        let resp = DeleteEntityResponse {
181            action: "deleted".to_string(),
182            entity_name: "auth-module".to_string(),
183            namespace: "global".to_string(),
184            relationships_removed: 3,
185            bindings_removed: 2,
186            elapsed_ms: 7,
187        };
188        let json = serde_json::to_value(&resp).expect("serialization failed");
189        assert_eq!(json["action"], "deleted");
190        assert_eq!(json["entity_name"], "auth-module");
191        assert_eq!(json["namespace"], "global");
192        assert_eq!(json["relationships_removed"], 3);
193        assert_eq!(json["bindings_removed"], 2);
194        assert!(json["elapsed_ms"].is_number());
195    }
196
197    #[test]
198    fn delete_entity_response_action_is_deleted() {
199        let resp = DeleteEntityResponse {
200            action: "deleted".to_string(),
201            entity_name: "x".to_string(),
202            namespace: "ns".to_string(),
203            relationships_removed: 0,
204            bindings_removed: 0,
205            elapsed_ms: 0,
206        };
207        let json = serde_json::to_value(&resp).expect("serialization failed");
208        assert_eq!(json["action"], "deleted");
209    }
210
211    #[test]
212    fn delete_entity_response_zero_counts_allowed() {
213        let resp = DeleteEntityResponse {
214            action: "deleted".to_string(),
215            entity_name: "orphan-entity".to_string(),
216            namespace: "global".to_string(),
217            relationships_removed: 0,
218            bindings_removed: 0,
219            elapsed_ms: 1,
220        };
221        let json = serde_json::to_value(&resp).expect("serialization failed");
222        assert_eq!(json["relationships_removed"], 0);
223        assert_eq!(json["bindings_removed"], 0);
224    }
225}