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 relationship between two existing graph entities\n \
14 sqlite-graphrag unlink --from oauth-flow --to refresh-tokens --relation related\n\n \
15 # If either entity or the relationship does not exist, the command exits with code 4.\n\n \
16NOTE:\n \
17 --from and --to expect ENTITY names (graph nodes), not memory names.\n \
18 To inspect current entities and relationships, run: sqlite-graphrag graph --format json")]
19pub struct UnlinkArgs {
20 #[arg(long, alias = "source", alias = "name")]
23 pub from: String,
24 #[arg(long, alias = "target")]
26 pub to: String,
27 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
30 pub relation: String,
31 #[arg(long)]
32 pub namespace: Option<String>,
33 #[arg(long, value_enum, default_value = "json")]
34 pub format: OutputFormat,
35 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
36 pub json: bool,
37 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
38 pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct UnlinkResponse {
43 action: String,
44 relationship_id: i64,
45 from_name: String,
46 to_name: String,
47 relation: String,
48 namespace: String,
49 elapsed_ms: u64,
51}
52
53pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
54 let inicio = std::time::Instant::now();
55 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
56 let paths = AppPaths::resolve(args.db.as_deref())?;
57
58 crate::storage::connection::ensure_db_ready(&paths)?;
59
60 let relation_str = &args.relation;
61 crate::parsers::warn_if_non_canonical(relation_str);
62
63 let mut conn = open_rw(&paths.db)?;
64
65 let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
66 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
67 let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
68 .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
69
70 let rel = entities::find_relationship(&conn, source_id, target_id, relation_str)?.ok_or_else(
71 || {
72 AppError::NotFound(errors_msg::relationship_not_found(
73 &args.from,
74 relation_str,
75 &args.to,
76 &namespace,
77 ))
78 },
79 )?;
80
81 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
82 entities::delete_relationship_by_id(&tx, rel.id)?;
83 entities::recalculate_degree(&tx, source_id)?;
84 entities::recalculate_degree(&tx, target_id)?;
85 tx.commit()?;
86
87 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
88
89 let response = UnlinkResponse {
90 action: "deleted".to_string(),
91 relationship_id: rel.id,
92 from_name: args.from.clone(),
93 to_name: args.to.clone(),
94 relation: relation_str.to_string(),
95 namespace: namespace.clone(),
96 elapsed_ms: inicio.elapsed().as_millis() as u64,
97 };
98
99 match args.format {
100 OutputFormat::Json => output::emit_json(&response)?,
101 OutputFormat::Text | OutputFormat::Markdown => {
102 output::emit_text(&format!(
103 "deleted: {} --[{}]--> {} [{}]",
104 response.from_name, response.relation, response.to_name, response.namespace
105 ));
106 }
107 }
108
109 Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn unlink_response_serializes_all_fields() {
118 let resp = UnlinkResponse {
119 action: "deleted".to_string(),
120 relationship_id: 99,
121 from_name: "entity-a".to_string(),
122 to_name: "entity-b".to_string(),
123 relation: "uses".to_string(),
124 namespace: "global".to_string(),
125 elapsed_ms: 5,
126 };
127 let json = serde_json::to_value(&resp).expect("serialization failed");
128 assert_eq!(json["action"], "deleted");
129 assert_eq!(json["relationship_id"], 99i64);
130 assert_eq!(json["from_name"], "entity-a");
131 assert_eq!(json["to_name"], "entity-b");
132 assert_eq!(json["relation"], "uses");
133 assert_eq!(json["namespace"], "global");
134 assert_eq!(json["elapsed_ms"], 5u64);
135 }
136
137 #[test]
138 fn unlink_response_action_must_be_deleted() {
139 let resp = UnlinkResponse {
140 action: "deleted".to_string(),
141 relationship_id: 1,
142 from_name: "a".to_string(),
143 to_name: "b".to_string(),
144 relation: "related".to_string(),
145 namespace: "global".to_string(),
146 elapsed_ms: 0,
147 };
148 let json = serde_json::to_value(&resp).expect("serialization failed");
149 assert_eq!(
150 json["action"], "deleted",
151 "unlink action must always be 'deleted'"
152 );
153 }
154
155 #[test]
156 fn unlink_response_relationship_id_positive() {
157 let resp = UnlinkResponse {
158 action: "deleted".to_string(),
159 relationship_id: 42,
160 from_name: "origin".to_string(),
161 to_name: "destination".to_string(),
162 relation: "supports".to_string(),
163 namespace: "project".to_string(),
164 elapsed_ms: 3,
165 };
166 let json = serde_json::to_value(&resp).expect("serialization failed");
167 assert!(
168 json["relationship_id"].as_i64().unwrap() > 0,
169 "relationship_id must be positive after unlink"
170 );
171 }
172}