1use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output::{self, OutputFormat};
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::entities;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n \
13 # Remove a specific relationship between two entities\n \
14 sqlite-graphrag unlink --from oauth-flow --to refresh-tokens --relation related\n\n \
15 # Remove ALL relationships between two entities (any relation type)\n \
16 sqlite-graphrag unlink --from oauth-flow --to refresh-tokens\n\n \
17 # Remove ALL relationships where an entity is source or target\n \
18 sqlite-graphrag unlink --entity oauth-flow --all\n\n \
19NOTE:\n \
20 --from and --to expect ENTITY names (graph nodes), not memory names.\n \
21 To inspect current entities and relationships, run: sqlite-graphrag graph --format json")]
22pub struct UnlinkArgs {
23 #[arg(long, alias = "source", alias = "name", conflicts_with = "entity")]
26 pub from: Option<String>,
27 #[arg(long, alias = "target", conflicts_with = "entity")]
29 pub to: Option<String>,
30 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
34 pub relation: Option<String>,
35 #[arg(long, conflicts_with_all = ["from", "to"])]
38 pub entity: Option<String>,
39 #[arg(long, requires = "entity")]
41 pub all: bool,
42 #[arg(long, requires = "entity", conflicts_with_all = ["from", "to", "all"], value_name = "NAME")]
47 pub memory: Option<String>,
48 #[arg(long)]
49 pub namespace: Option<String>,
50 #[arg(long, value_enum, default_value = "json")]
51 pub format: OutputFormat,
52 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
53 pub json: bool,
54 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
55 pub db: Option<String>,
56}
57
58#[derive(Serialize)]
59struct UnlinkResponse {
60 action: String,
61 from_name: String,
62 to_name: String,
63 relation: String,
64 relationships_removed: u64,
65 namespace: String,
66 elapsed_ms: u64,
68}
69
70pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
71 let inicio = std::time::Instant::now();
72 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
73 let paths = AppPaths::resolve(args.db.as_deref())?;
74
75 crate::storage::connection::ensure_db_ready(&paths)?;
76
77 if let Some(relation_str) = &args.relation {
78 crate::parsers::warn_if_non_canonical(relation_str);
79 }
80
81 let mut conn = open_rw(&paths.db)?;
82
83 if let Some(memory_name) = args.memory.as_deref() {
86 let entity_name = args.entity.as_deref().ok_or_else(|| {
87 AppError::Validation("--entity is required when --memory is used".to_string())
88 })?;
89 let memory_id = crate::storage::memories::find_by_name(&conn, &namespace, memory_name)?
90 .map(|(id, _, _)| id)
91 .ok_or_else(|| AppError::MemoryNotFound {
92 name: memory_name.to_string(),
93 namespace: namespace.clone(),
94 })?;
95 let entity_id =
96 entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
97 AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
98 })?;
99
100 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
101 let removed = entities::unlink_memory_entity(&tx, memory_id, entity_id)?;
102 entities::recalculate_degree(&tx, entity_id)?;
103 tx.commit()?;
104
105 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
106
107 let response = UnlinkResponse {
108 action: if removed > 0 {
109 "deleted".to_string()
110 } else {
111 "noop".to_string()
112 },
113 from_name: memory_name.to_string(),
114 to_name: entity_name.to_string(),
115 relation: "memory-entity".to_string(),
116 relationships_removed: removed,
117 namespace: namespace.clone(),
118 elapsed_ms: inicio.elapsed().as_millis() as u64,
119 };
120
121 match args.format {
122 OutputFormat::Json => output::emit_json(&response)?,
123 OutputFormat::Text | OutputFormat::Markdown => {
124 output::emit_text(&format!(
125 "{}: memory '{}' --[memory-entity]--> entity '{}' removed {} binding(s) [{}]",
126 response.action,
127 response.from_name,
128 response.to_name,
129 response.relationships_removed,
130 response.namespace
131 ));
132 }
133 }
134 return Ok(());
135 }
136
137 if args.entity.is_some() && !args.all {
139 return Err(AppError::Validation(
140 "--entity must be combined with --all (remove all relationships) or --memory <name> (remove a memory↔entity binding)"
141 .to_string(),
142 ));
143 }
144
145 if args.all {
147 let entity_name = args.entity.as_deref().unwrap_or("");
148 let entity_id =
149 entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
150 AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
151 })?;
152
153 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
154 let removed = delete_all_entity_relationships(&tx, entity_id)?;
155 entities::recalculate_degree(&tx, entity_id)?;
156 tx.commit()?;
157
158 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
159
160 let response = UnlinkResponse {
161 action: "deleted".to_string(),
162 from_name: entity_name.to_string(),
163 to_name: "*".to_string(),
164 relation: "*".to_string(),
165 relationships_removed: removed,
166 namespace: namespace.clone(),
167 elapsed_ms: inicio.elapsed().as_millis() as u64,
168 };
169
170 match args.format {
171 OutputFormat::Json => output::emit_json(&response)?,
172 OutputFormat::Text | OutputFormat::Markdown => {
173 output::emit_text(&format!(
174 "deleted: {} --[*]--> * removed {} relationship(s) [{}]",
175 response.from_name, response.relationships_removed, response.namespace
176 ));
177 }
178 }
179 return Ok(());
180 }
181
182 let from_name = args.from.as_deref().ok_or_else(|| {
184 AppError::Validation("--from is required when --entity/--all is not used".to_string())
185 })?;
186 let to_name = args.to.as_deref().ok_or_else(|| {
187 AppError::Validation("--to is required when --entity/--all is not used".to_string())
188 })?;
189
190 let source_id = entities::find_entity_id(&conn, &namespace, from_name)?
191 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(from_name, &namespace)))?;
192 let target_id = entities::find_entity_id(&conn, &namespace, to_name)?
193 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(to_name, &namespace)))?;
194
195 let (removed, relation_display) = if let Some(rel) = args.relation.as_deref() {
196 let row =
198 entities::find_relationship(&conn, source_id, target_id, rel)?.ok_or_else(|| {
199 AppError::NotFound(errors_msg::relationship_not_found(
200 from_name, rel, to_name, &namespace,
201 ))
202 })?;
203
204 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
205 entities::delete_relationship_by_id(&tx, row.id)?;
206 entities::recalculate_degree(&tx, source_id)?;
207 entities::recalculate_degree(&tx, target_id)?;
208 tx.commit()?;
209
210 (1u64, rel.to_string())
211 } else {
212 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
214 let count = delete_relationships_between(&tx, source_id, target_id)?;
215 entities::recalculate_degree(&tx, source_id)?;
216 entities::recalculate_degree(&tx, target_id)?;
217 tx.commit()?;
218
219 (count, "*".to_string())
220 };
221
222 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
223
224 let response = UnlinkResponse {
225 action: "deleted".to_string(),
226 from_name: from_name.to_string(),
227 to_name: to_name.to_string(),
228 relation: relation_display.clone(),
229 relationships_removed: removed,
230 namespace: namespace.clone(),
231 elapsed_ms: inicio.elapsed().as_millis() as u64,
232 };
233
234 match args.format {
235 OutputFormat::Json => output::emit_json(&response)?,
236 OutputFormat::Text | OutputFormat::Markdown => {
237 output::emit_text(&format!(
238 "deleted: {} --[{}]--> {} removed {} relationship(s) [{}]",
239 response.from_name,
240 response.relation,
241 response.to_name,
242 response.relationships_removed,
243 response.namespace
244 ));
245 }
246 }
247
248 Ok(())
249}
250
251fn delete_all_entity_relationships(
254 conn: &rusqlite::Connection,
255 entity_id: i64,
256) -> Result<u64, AppError> {
257 let mut stmt =
259 conn.prepare_cached("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
260 let ids: Vec<i64> = stmt
261 .query_map(rusqlite::params![entity_id], |r| r.get(0))?
262 .collect::<rusqlite::Result<Vec<_>>>()?;
263
264 let count = ids.len() as u64;
265 for rel_id in ids {
266 conn.execute(
267 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
268 rusqlite::params![rel_id],
269 )?;
270 conn.execute(
271 "DELETE FROM relationships WHERE id = ?1",
272 rusqlite::params![rel_id],
273 )?;
274 }
275 Ok(count)
276}
277
278fn delete_relationships_between(
281 conn: &rusqlite::Connection,
282 source_id: i64,
283 target_id: i64,
284) -> Result<u64, AppError> {
285 let mut stmt = conn
286 .prepare_cached("SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2")?;
287 let ids: Vec<i64> = stmt
288 .query_map(rusqlite::params![source_id, target_id], |r| r.get(0))?
289 .collect::<rusqlite::Result<Vec<_>>>()?;
290
291 let count = ids.len() as u64;
292 for rel_id in ids {
293 conn.execute(
294 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
295 rusqlite::params![rel_id],
296 )?;
297 conn.execute(
298 "DELETE FROM relationships WHERE id = ?1",
299 rusqlite::params![rel_id],
300 )?;
301 }
302 Ok(count)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn unlink_response_serializes_all_fields() {
311 let resp = UnlinkResponse {
312 action: "deleted".to_string(),
313 from_name: "entity-a".to_string(),
314 to_name: "entity-b".to_string(),
315 relation: "uses".to_string(),
316 relationships_removed: 1,
317 namespace: "global".to_string(),
318 elapsed_ms: 5,
319 };
320 let json = serde_json::to_value(&resp).expect("serialization failed");
321 assert_eq!(json["action"], "deleted");
322 assert_eq!(json["from_name"], "entity-a");
323 assert_eq!(json["to_name"], "entity-b");
324 assert_eq!(json["relation"], "uses");
325 assert_eq!(json["relationships_removed"], 1u64);
326 assert_eq!(json["namespace"], "global");
327 assert_eq!(json["elapsed_ms"], 5u64);
328 }
329
330 #[test]
331 fn unlink_response_action_must_be_deleted() {
332 let resp = UnlinkResponse {
333 action: "deleted".to_string(),
334 from_name: "a".to_string(),
335 to_name: "b".to_string(),
336 relation: "related".to_string(),
337 relationships_removed: 1,
338 namespace: "global".to_string(),
339 elapsed_ms: 0,
340 };
341 let json = serde_json::to_value(&resp).expect("serialization failed");
342 assert_eq!(
343 json["action"], "deleted",
344 "unlink action must always be 'deleted'"
345 );
346 }
347
348 #[test]
349 fn unlink_response_bulk_uses_wildcard_relation() {
350 let resp = UnlinkResponse {
351 action: "deleted".to_string(),
352 from_name: "origin".to_string(),
353 to_name: "destination".to_string(),
354 relation: "*".to_string(),
355 relationships_removed: 3,
356 namespace: "project".to_string(),
357 elapsed_ms: 3,
358 };
359 let json = serde_json::to_value(&resp).expect("serialization failed");
360 assert_eq!(json["relation"], "*");
361 assert_eq!(json["relationships_removed"], 3u64);
362 }
363
364 #[test]
365 fn unlink_response_entity_all_uses_wildcard_to() {
366 let resp = UnlinkResponse {
367 action: "deleted".to_string(),
368 from_name: "oauth-flow".to_string(),
369 to_name: "*".to_string(),
370 relation: "*".to_string(),
371 relationships_removed: 5,
372 namespace: "global".to_string(),
373 elapsed_ms: 2,
374 };
375 let json = serde_json::to_value(&resp).expect("serialization failed");
376 assert_eq!(json["to_name"], "*");
377 assert_eq!(json["relation"], "*");
378 assert_eq!(json["relationships_removed"], 5u64);
379 }
380
381 #[test]
383 fn unlink_memory_entity_binding_mode_parses() {
384 use crate::cli::{Cli, Commands};
385 use clap::Parser;
386 let cli = Cli::try_parse_from([
387 "sqlite-graphrag",
388 "unlink",
389 "--memory",
390 "my-mem",
391 "--entity",
392 "jwt-token",
393 ])
394 .expect("parse");
395 match cli.command {
396 Some(Commands::Unlink(a)) => {
397 assert_eq!(a.memory.as_deref(), Some("my-mem"));
398 assert_eq!(a.entity.as_deref(), Some("jwt-token"));
399 assert!(!a.all);
400 }
401 other => panic!("expected unlink, got {other:?}"),
402 }
403 }
404
405 #[test]
406 fn unlink_response_relationships_removed_field_present() {
407 let resp = UnlinkResponse {
408 action: "deleted".to_string(),
409 from_name: "a".to_string(),
410 to_name: "b".to_string(),
411 relation: "uses".to_string(),
412 relationships_removed: 0,
413 namespace: "global".to_string(),
414 elapsed_ms: 0,
415 };
416 let json = serde_json::to_value(&resp).expect("serialization failed");
417 assert!(
418 json.get("relationships_removed").is_some(),
419 "relationships_removed field must be present"
420 );
421 }
422}