sqlite_graphrag/commands/
unlink.rs1use 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, requires = "all", conflicts_with_all = ["from", "to"])]
37 pub entity: Option<String>,
38 #[arg(long, requires = "entity")]
40 pub all: bool,
41 #[arg(long)]
42 pub namespace: Option<String>,
43 #[arg(long, value_enum, default_value = "json")]
44 pub format: OutputFormat,
45 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
46 pub json: bool,
47 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
48 pub db: Option<String>,
49}
50
51#[derive(Serialize)]
52struct UnlinkResponse {
53 action: String,
54 from_name: String,
55 to_name: String,
56 relation: String,
57 relationships_removed: u64,
58 namespace: String,
59 elapsed_ms: u64,
61}
62
63pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
64 let inicio = std::time::Instant::now();
65 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
66 let paths = AppPaths::resolve(args.db.as_deref())?;
67
68 crate::storage::connection::ensure_db_ready(&paths)?;
69
70 if let Some(relation_str) = &args.relation {
71 crate::parsers::warn_if_non_canonical(relation_str);
72 }
73
74 let mut conn = open_rw(&paths.db)?;
75
76 if args.all {
78 let entity_name = args.entity.as_deref().unwrap_or("");
79 let entity_id =
80 entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
81 AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
82 })?;
83
84 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
85 let removed = delete_all_entity_relationships(&tx, entity_id)?;
86 entities::recalculate_degree(&tx, entity_id)?;
87 tx.commit()?;
88
89 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
90
91 let response = UnlinkResponse {
92 action: "deleted".to_string(),
93 from_name: entity_name.to_string(),
94 to_name: "*".to_string(),
95 relation: "*".to_string(),
96 relationships_removed: removed,
97 namespace: namespace.clone(),
98 elapsed_ms: inicio.elapsed().as_millis() as u64,
99 };
100
101 match args.format {
102 OutputFormat::Json => output::emit_json(&response)?,
103 OutputFormat::Text | OutputFormat::Markdown => {
104 output::emit_text(&format!(
105 "deleted: {} --[*]--> * removed {} relationship(s) [{}]",
106 response.from_name, response.relationships_removed, response.namespace
107 ));
108 }
109 }
110 return Ok(());
111 }
112
113 let from_name = args.from.as_deref().ok_or_else(|| {
115 AppError::Validation("--from is required when --entity/--all is not used".to_string())
116 })?;
117 let to_name = args.to.as_deref().ok_or_else(|| {
118 AppError::Validation("--to is required when --entity/--all is not used".to_string())
119 })?;
120
121 let source_id = entities::find_entity_id(&conn, &namespace, from_name)?
122 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(from_name, &namespace)))?;
123 let target_id = entities::find_entity_id(&conn, &namespace, to_name)?
124 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(to_name, &namespace)))?;
125
126 let (removed, relation_display) = if let Some(rel) = args.relation.as_deref() {
127 let row =
129 entities::find_relationship(&conn, source_id, target_id, rel)?.ok_or_else(|| {
130 AppError::NotFound(errors_msg::relationship_not_found(
131 from_name, rel, to_name, &namespace,
132 ))
133 })?;
134
135 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
136 entities::delete_relationship_by_id(&tx, row.id)?;
137 entities::recalculate_degree(&tx, source_id)?;
138 entities::recalculate_degree(&tx, target_id)?;
139 tx.commit()?;
140
141 (1u64, rel.to_string())
142 } else {
143 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
145 let count = delete_relationships_between(&tx, source_id, target_id)?;
146 entities::recalculate_degree(&tx, source_id)?;
147 entities::recalculate_degree(&tx, target_id)?;
148 tx.commit()?;
149
150 (count, "*".to_string())
151 };
152
153 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
154
155 let response = UnlinkResponse {
156 action: "deleted".to_string(),
157 from_name: from_name.to_string(),
158 to_name: to_name.to_string(),
159 relation: relation_display.clone(),
160 relationships_removed: removed,
161 namespace: namespace.clone(),
162 elapsed_ms: inicio.elapsed().as_millis() as u64,
163 };
164
165 match args.format {
166 OutputFormat::Json => output::emit_json(&response)?,
167 OutputFormat::Text | OutputFormat::Markdown => {
168 output::emit_text(&format!(
169 "deleted: {} --[{}]--> {} removed {} relationship(s) [{}]",
170 response.from_name,
171 response.relation,
172 response.to_name,
173 response.relationships_removed,
174 response.namespace
175 ));
176 }
177 }
178
179 Ok(())
180}
181
182fn delete_all_entity_relationships(
185 conn: &rusqlite::Connection,
186 entity_id: i64,
187) -> Result<u64, AppError> {
188 let mut stmt =
190 conn.prepare("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
191 let ids: Vec<i64> = stmt
192 .query_map(rusqlite::params![entity_id], |r| r.get(0))?
193 .collect::<rusqlite::Result<Vec<_>>>()?;
194
195 let count = ids.len() as u64;
196 for rel_id in ids {
197 conn.execute(
198 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
199 rusqlite::params![rel_id],
200 )?;
201 conn.execute(
202 "DELETE FROM relationships WHERE id = ?1",
203 rusqlite::params![rel_id],
204 )?;
205 }
206 Ok(count)
207}
208
209fn delete_relationships_between(
212 conn: &rusqlite::Connection,
213 source_id: i64,
214 target_id: i64,
215) -> Result<u64, AppError> {
216 let mut stmt =
217 conn.prepare("SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2")?;
218 let ids: Vec<i64> = stmt
219 .query_map(rusqlite::params![source_id, target_id], |r| r.get(0))?
220 .collect::<rusqlite::Result<Vec<_>>>()?;
221
222 let count = ids.len() as u64;
223 for rel_id in ids {
224 conn.execute(
225 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
226 rusqlite::params![rel_id],
227 )?;
228 conn.execute(
229 "DELETE FROM relationships WHERE id = ?1",
230 rusqlite::params![rel_id],
231 )?;
232 }
233 Ok(count)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn unlink_response_serializes_all_fields() {
242 let resp = UnlinkResponse {
243 action: "deleted".to_string(),
244 from_name: "entity-a".to_string(),
245 to_name: "entity-b".to_string(),
246 relation: "uses".to_string(),
247 relationships_removed: 1,
248 namespace: "global".to_string(),
249 elapsed_ms: 5,
250 };
251 let json = serde_json::to_value(&resp).expect("serialization failed");
252 assert_eq!(json["action"], "deleted");
253 assert_eq!(json["from_name"], "entity-a");
254 assert_eq!(json["to_name"], "entity-b");
255 assert_eq!(json["relation"], "uses");
256 assert_eq!(json["relationships_removed"], 1u64);
257 assert_eq!(json["namespace"], "global");
258 assert_eq!(json["elapsed_ms"], 5u64);
259 }
260
261 #[test]
262 fn unlink_response_action_must_be_deleted() {
263 let resp = UnlinkResponse {
264 action: "deleted".to_string(),
265 from_name: "a".to_string(),
266 to_name: "b".to_string(),
267 relation: "related".to_string(),
268 relationships_removed: 1,
269 namespace: "global".to_string(),
270 elapsed_ms: 0,
271 };
272 let json = serde_json::to_value(&resp).expect("serialization failed");
273 assert_eq!(
274 json["action"], "deleted",
275 "unlink action must always be 'deleted'"
276 );
277 }
278
279 #[test]
280 fn unlink_response_bulk_uses_wildcard_relation() {
281 let resp = UnlinkResponse {
282 action: "deleted".to_string(),
283 from_name: "origin".to_string(),
284 to_name: "destination".to_string(),
285 relation: "*".to_string(),
286 relationships_removed: 3,
287 namespace: "project".to_string(),
288 elapsed_ms: 3,
289 };
290 let json = serde_json::to_value(&resp).expect("serialization failed");
291 assert_eq!(json["relation"], "*");
292 assert_eq!(json["relationships_removed"], 3u64);
293 }
294
295 #[test]
296 fn unlink_response_entity_all_uses_wildcard_to() {
297 let resp = UnlinkResponse {
298 action: "deleted".to_string(),
299 from_name: "oauth-flow".to_string(),
300 to_name: "*".to_string(),
301 relation: "*".to_string(),
302 relationships_removed: 5,
303 namespace: "global".to_string(),
304 elapsed_ms: 2,
305 };
306 let json = serde_json::to_value(&resp).expect("serialization failed");
307 assert_eq!(json["to_name"], "*");
308 assert_eq!(json["relation"], "*");
309 assert_eq!(json["relationships_removed"], 5u64);
310 }
311
312 #[test]
313 fn unlink_response_relationships_removed_field_present() {
314 let resp = UnlinkResponse {
315 action: "deleted".to_string(),
316 from_name: "a".to_string(),
317 to_name: "b".to_string(),
318 relation: "uses".to_string(),
319 relationships_removed: 0,
320 namespace: "global".to_string(),
321 elapsed_ms: 0,
322 };
323 let json = serde_json::to_value(&resp).expect("serialization failed");
324 assert!(
325 json.get("relationships_removed").is_some(),
326 "relationships_removed field must be present"
327 );
328 }
329}