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