sqlite_graphrag/commands/
delete_entity.rs1use 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 {
32 #[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 #[arg(long, required_unless_present = "name_positional")]
49 pub name: Option<String>,
50 #[arg(long, default_value_t = false)]
54 pub cascade: bool,
55 #[arg(long)]
57 pub namespace: Option<String>,
58 #[arg(long, value_enum, default_value = "json")]
60 pub format: OutputFormat,
61 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
63 pub json: bool,
64 #[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 elapsed_ms: u64,
78}
79
80pub 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 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 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 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 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 let relationships_removed = tx.execute(
146 "DELETE FROM relationships WHERE source_id = ?1 OR target_id = ?1",
147 params![entity_id],
148 )?;
149
150 let bindings_removed = tx.execute(
152 "DELETE FROM memory_entities WHERE entity_id = ?1",
153 params![entity_id],
154 )?;
155
156 let _ = tx.execute(
158 "DELETE FROM vec_entities WHERE entity_id = ?1",
159 params![entity_id],
160 );
161
162 tx.execute("DELETE FROM entities WHERE id = ?1", params![entity_id])?;
164
165 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}