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