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 as a positional argument. Alternative to `--name`.
33    ///
34    /// GAP-SG-272: matches the spelling `read` and `related` have always accepted.
35    #[arg(
36        value_name = "NAME",
37        conflicts_with = "name",
38        help = "Entity name (kebab-case slug); alternative to --name"
39    )]
40    pub name_positional: Option<String>,
41    /// Entity name to delete (graph node, not memory name).
42    ///
43    /// `Option` rather than a bare `String`, and required only when the positional
44    /// is absent: clap enforces "exactly one of the two" through the pair of
45    /// attributes, so the handler never has to decide what an empty designation
46    /// means. This verb DELETES, so failing closed in the parser is worth more
47    /// than the convenience of a non-optional field.
48    #[arg(long, required_unless_present = "name_positional")]
49    pub name: Option<String>,
50    /// Required confirmation flag. Without it the command exits with an error.
51    ///
52    /// Deletes all relationships and memory bindings attached to this entity.
53    #[arg(long, default_value_t = false)]
54    pub cascade: bool,
55    /// Namespace scope.
56    #[arg(long)]
57    pub namespace: Option<String>,
58    /// Output format.
59    #[arg(long, value_enum, default_value = "json")]
60    pub format: OutputFormat,
61    /// Emit machine-readable JSON on stdout.
62    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
63    pub json: bool,
64    /// Path to the SQLite database file.
65    #[arg(long)]
66    pub db: Option<String>,
67}
68
69#[derive(Serialize)]
70struct DeleteEntityResponse {
71    action: String,
72    entity_name: String,
73    namespace: String,
74    relationships_removed: usize,
75    bindings_removed: usize,
76    /// Total execution time in milliseconds from handler start to serialisation.
77    elapsed_ms: u64,
78}
79
80/// Run.
81pub fn run(args: DeleteEntityArgs) -> Result<(), AppError> {
82    let started = std::time::Instant::now();
83
84    if !args.cascade {
85        return Err(AppError::Validation(
86            "use --cascade to confirm deletion of entity and all its relationships".to_string(),
87        ));
88    }
89
90    // GAP-SG-272: the two spellings are mutually exclusive in the parser and one
91    // of them is mandatory, so the `ok_or_else` arm is unreachable through the
92    // CLI. It stays because this function is also callable as a library, where no
93    // clap attribute is enforcing anything.
94    let entity_name: &str = args
95        .name_positional
96        .as_deref()
97        .or(args.name.as_deref())
98        .ok_or_else(|| {
99            AppError::Validation(crate::i18n::validation::name_required_positional_or_flag())
100        })?;
101
102    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
103    let paths = AppPaths::resolve(args.db.as_deref())?;
104
105    crate::storage::connection::ensure_db_ready(&paths)?;
106
107    let mut conn = open_rw(&paths.db)?;
108
109    let entity_id = entities::find_entity_id(&conn, &namespace, entity_name)?
110        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace)))?;
111
112    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
113
114    // Step 0: collect adjacent entity IDs BEFORE deleting relationships.
115    let adjacent_ids: Vec<i64> = {
116        let mut stmt = tx.prepare(
117            "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
118             FROM relationships WHERE source_id = ?1 OR target_id = ?1",
119        )?;
120        let ids: Vec<i64> = stmt
121            .query_map(params![entity_id], |r| r.get(0))?
122            .collect::<Result<Vec<_>, _>>()?;
123        ids
124    };
125
126    // Step 1: collect relationship IDs for this entity (source or target).
127    let rel_ids: Vec<i64> = {
128        let mut stmt =
129            tx.prepare("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
130        let ids: Vec<i64> = stmt
131            .query_map(params![entity_id], |r| r.get::<_, i64>(0))?
132            .collect::<Result<Vec<_>, _>>()?;
133        ids
134    };
135
136    // Step 2: delete memory_relationships for each collected relationship id.
137    for &rel_id in &rel_ids {
138        tx.execute(
139            "DELETE FROM memory_relationships WHERE relationship_id = ?1",
140            params![rel_id],
141        )?;
142    }
143
144    // Step 3: delete the relationships themselves.
145    let relationships_removed = tx.execute(
146        "DELETE FROM relationships WHERE source_id = ?1 OR target_id = ?1",
147        params![entity_id],
148    )?;
149
150    // Step 4: delete memory_entities bindings.
151    let bindings_removed = tx.execute(
152        "DELETE FROM memory_entities WHERE entity_id = ?1",
153        params![entity_id],
154    )?;
155
156    // Step 5: delete vec_entities row (ignore error — row may not exist).
157    let _ = tx.execute(
158        "DELETE FROM vec_entities WHERE entity_id = ?1",
159        params![entity_id],
160    );
161
162    // Step 6: delete the entity itself.
163    tx.execute("DELETE FROM entities WHERE id = ?1", params![entity_id])?;
164
165    // Step 7: recalculate degree for adjacent entities that lost relationships.
166    for &adj_id in &adjacent_ids {
167        if adj_id != entity_id {
168            entities::recalculate_degree(&tx, adj_id)?;
169        }
170    }
171
172    tx.commit()?;
173
174    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
175
176    let response = DeleteEntityResponse {
177        action: "deleted".to_string(),
178        entity_name: entity_name.to_string(),
179        namespace: namespace.clone(),
180        relationships_removed,
181        bindings_removed,
182        elapsed_ms: started.elapsed().as_millis() as u64,
183    };
184
185    match args.format {
186        OutputFormat::Json => output::emit_json(&response)?,
187        OutputFormat::Text | OutputFormat::Markdown => {
188            output::emit_text(&format!(
189                "deleted: {} (relationships_removed={}, bindings_removed={}) [{}]",
190                response.entity_name,
191                response.relationships_removed,
192                response.bindings_removed,
193                response.namespace
194            ));
195        }
196    }
197
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn delete_entity_response_serializes_all_fields() {
207        let resp = DeleteEntityResponse {
208            action: "deleted".to_string(),
209            entity_name: "auth-module".to_string(),
210            namespace: "global".to_string(),
211            relationships_removed: 3,
212            bindings_removed: 2,
213            elapsed_ms: 7,
214        };
215        let json = serde_json::to_value(&resp).expect("serialization failed");
216        assert_eq!(json["action"], "deleted");
217        assert_eq!(json["entity_name"], "auth-module");
218        assert_eq!(json["namespace"], "global");
219        assert_eq!(json["relationships_removed"], 3);
220        assert_eq!(json["bindings_removed"], 2);
221        assert!(json["elapsed_ms"].is_number());
222    }
223
224    #[test]
225    fn delete_entity_response_action_is_deleted() {
226        let resp = DeleteEntityResponse {
227            action: "deleted".to_string(),
228            entity_name: "x".to_string(),
229            namespace: "ns".to_string(),
230            relationships_removed: 0,
231            bindings_removed: 0,
232            elapsed_ms: 0,
233        };
234        let json = serde_json::to_value(&resp).expect("serialization failed");
235        assert_eq!(json["action"], "deleted");
236    }
237
238    #[test]
239    fn delete_entity_response_zero_counts_allowed() {
240        let resp = DeleteEntityResponse {
241            action: "deleted".to_string(),
242            entity_name: "orphan-entity".to_string(),
243            namespace: "global".to_string(),
244            relationships_removed: 0,
245            bindings_removed: 0,
246            elapsed_ms: 1,
247        };
248        let json = serde_json::to_value(&resp).expect("serialization failed");
249        assert_eq!(json["relationships_removed"], 0);
250        assert_eq!(json["bindings_removed"], 0);
251    }
252}