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 1: collect relationship IDs for this entity (source or target).
82    let rel_ids: Vec<i64> = {
83        let mut stmt =
84            tx.prepare("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
85        let ids: Vec<i64> = stmt
86            .query_map(params![entity_id], |r| r.get::<_, i64>(0))?
87            .collect::<Result<Vec<_>, _>>()?;
88        ids
89    };
90
91    // Step 2: delete memory_relationships for each collected relationship id.
92    for &rel_id in &rel_ids {
93        tx.execute(
94            "DELETE FROM memory_relationships WHERE relationship_id = ?1",
95            params![rel_id],
96        )?;
97    }
98
99    // Step 3: delete the relationships themselves.
100    let relationships_removed = tx.execute(
101        "DELETE FROM relationships WHERE source_id = ?1 OR target_id = ?1",
102        params![entity_id],
103    )?;
104
105    // Step 4: delete memory_entities bindings.
106    let bindings_removed = tx.execute(
107        "DELETE FROM memory_entities WHERE entity_id = ?1",
108        params![entity_id],
109    )?;
110
111    // Step 5: delete vec_entities row (ignore error — row may not exist).
112    let _ = tx.execute(
113        "DELETE FROM vec_entities WHERE entity_id = ?1",
114        params![entity_id],
115    );
116
117    // Step 6: delete the entity itself.
118    tx.execute("DELETE FROM entities WHERE id = ?1", params![entity_id])?;
119
120    tx.commit()?;
121
122    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
123
124    let response = DeleteEntityResponse {
125        action: "deleted".to_string(),
126        entity_name: args.name.clone(),
127        namespace: namespace.clone(),
128        relationships_removed,
129        bindings_removed,
130        elapsed_ms: inicio.elapsed().as_millis() as u64,
131    };
132
133    match args.format {
134        OutputFormat::Json => output::emit_json(&response)?,
135        OutputFormat::Text | OutputFormat::Markdown => {
136            output::emit_text(&format!(
137                "deleted: {} (relationships_removed={}, bindings_removed={}) [{}]",
138                response.entity_name,
139                response.relationships_removed,
140                response.bindings_removed,
141                response.namespace
142            ));
143        }
144    }
145
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn delete_entity_response_serializes_all_fields() {
155        let resp = DeleteEntityResponse {
156            action: "deleted".to_string(),
157            entity_name: "auth-module".to_string(),
158            namespace: "global".to_string(),
159            relationships_removed: 3,
160            bindings_removed: 2,
161            elapsed_ms: 7,
162        };
163        let json = serde_json::to_value(&resp).expect("serialization failed");
164        assert_eq!(json["action"], "deleted");
165        assert_eq!(json["entity_name"], "auth-module");
166        assert_eq!(json["namespace"], "global");
167        assert_eq!(json["relationships_removed"], 3);
168        assert_eq!(json["bindings_removed"], 2);
169        assert!(json["elapsed_ms"].is_number());
170    }
171
172    #[test]
173    fn delete_entity_response_action_is_deleted() {
174        let resp = DeleteEntityResponse {
175            action: "deleted".to_string(),
176            entity_name: "x".to_string(),
177            namespace: "ns".to_string(),
178            relationships_removed: 0,
179            bindings_removed: 0,
180            elapsed_ms: 0,
181        };
182        let json = serde_json::to_value(&resp).expect("serialization failed");
183        assert_eq!(json["action"], "deleted");
184    }
185
186    #[test]
187    fn delete_entity_response_zero_counts_allowed() {
188        let resp = DeleteEntityResponse {
189            action: "deleted".to_string(),
190            entity_name: "orphan-entity".to_string(),
191            namespace: "global".to_string(),
192            relationships_removed: 0,
193            bindings_removed: 0,
194            elapsed_ms: 1,
195        };
196        let json = serde_json::to_value(&resp).expect("serialization failed");
197        assert_eq!(json["relationships_removed"], 0);
198        assert_eq!(json["bindings_removed"], 0);
199    }
200}