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