sqlite_graphrag/commands/
link.rs1use crate::cli::RelationKind;
4use crate::constants::DEFAULT_RELATION_WEIGHT;
5use crate::entity_type::EntityType;
6use crate::errors::AppError;
7use crate::i18n::{errors_msg, validation};
8use crate::output::{self, OutputFormat};
9use crate::paths::AppPaths;
10use crate::storage::connection::open_rw;
11use crate::storage::entities;
12use crate::storage::entities::NewEntity;
13use serde::Serialize;
14
15#[derive(clap::Args)]
16#[command(after_long_help = "EXAMPLES:\n \
17 # Link two existing graph entities (extracted by BERT NER during `remember`)\n \
18 sqlite-graphrag link --from oauth-flow --to refresh-tokens --relation related\n\n \
19 # Auto-create entities that don't exist yet\n \
20 sqlite-graphrag link --from concept-a --to concept-b --relation depends-on --create-missing\n\n \
21 # Specify entity type for auto-created entities\n \
22 sqlite-graphrag link --from alice --to acme-corp --relation related --create-missing --entity-type person\n\n \
23 # If the entity does not exist and --create-missing is not set, the command fails with exit 4.\n \
24 # To list current entity names:\n \
25 sqlite-graphrag graph entities | jaq '.entities[].name'\n\n \
26NOTE:\n \
27 --from and --to expect ENTITY names (graph nodes), not memory names.\n \
28 Memory names are managed via remember/read/edit/forget; entities are auto-extracted\n \
29 by BERT NER from memory bodies or auto-created via --create-missing.")]
30pub struct LinkArgs {
31 #[arg(long, alias = "name")]
35 pub from: String,
36 #[arg(long)]
38 pub to: String,
39 #[arg(long, value_enum)]
40 pub relation: RelationKind,
41 #[arg(long)]
42 pub weight: Option<f64>,
43 #[arg(long)]
44 pub namespace: Option<String>,
45 #[arg(long, value_enum, default_value = "json")]
46 pub format: OutputFormat,
47 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
48 pub json: bool,
49 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
50 pub db: Option<String>,
51 #[arg(long, default_value_t = false)]
54 pub create_missing: bool,
55 #[arg(long, value_enum, default_value = "concept")]
57 pub entity_type: EntityType,
58}
59
60#[derive(Serialize)]
61struct LinkResponse {
62 action: String,
63 from: String,
64 to: String,
65 relation: String,
66 weight: f64,
67 namespace: String,
68 elapsed_ms: u64,
70 #[serde(skip_serializing_if = "Vec::is_empty")]
72 created_entities: Vec<String>,
73}
74
75pub fn run(args: LinkArgs) -> Result<(), AppError> {
76 let inicio = std::time::Instant::now();
77 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
78 let paths = AppPaths::resolve(args.db.as_deref())?;
79
80 if args.from == args.to {
81 return Err(AppError::Validation(validation::self_referential_link()));
82 }
83
84 let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
85 if !(0.0..=1.0).contains(&weight) {
86 return Err(AppError::Validation(validation::invalid_link_weight(
87 weight,
88 )));
89 }
90
91 crate::storage::connection::ensure_db_ready(&paths)?;
92
93 let relation_str = args.relation.as_str();
94
95 let mut conn = open_rw(&paths.db)?;
96 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
97
98 let mut created_entities: Vec<String> = Vec::new();
99
100 let source_id = match entities::find_entity_id(&tx, &namespace, &args.from)? {
101 Some(id) => id,
102 None if args.create_missing => {
103 let new_entity = NewEntity {
104 name: args.from.clone(),
105 entity_type: args.entity_type,
106 description: None,
107 };
108 created_entities.push(args.from.clone());
109 entities::upsert_entity(&tx, &namespace, &new_entity)?
110 }
111 None => {
112 return Err(AppError::NotFound(errors_msg::entity_not_found(
113 &args.from, &namespace,
114 )));
115 }
116 };
117
118 let target_id = match entities::find_entity_id(&tx, &namespace, &args.to)? {
119 Some(id) => id,
120 None if args.create_missing => {
121 let new_entity = NewEntity {
122 name: args.to.clone(),
123 entity_type: args.entity_type,
124 description: None,
125 };
126 created_entities.push(args.to.clone());
127 entities::upsert_entity(&tx, &namespace, &new_entity)?
128 }
129 None => {
130 return Err(AppError::NotFound(errors_msg::entity_not_found(
131 &args.to, &namespace,
132 )));
133 }
134 };
135
136 let (_rel_id, was_created) = entities::create_or_fetch_relationship(
137 &tx,
138 &namespace,
139 source_id,
140 target_id,
141 relation_str,
142 weight,
143 None,
144 )?;
145
146 if was_created {
147 entities::recalculate_degree(&tx, source_id)?;
148 entities::recalculate_degree(&tx, target_id)?;
149 }
150 tx.commit()?;
151
152 let action = if was_created {
153 "created".to_string()
154 } else {
155 "already_exists".to_string()
156 };
157
158 let response = LinkResponse {
159 action: action.clone(),
160 from: args.from.clone(),
161 to: args.to.clone(),
162 relation: relation_str.to_string(),
163 weight,
164 namespace: namespace.clone(),
165 elapsed_ms: inicio.elapsed().as_millis() as u64,
166 created_entities,
167 };
168
169 match args.format {
170 OutputFormat::Json => output::emit_json(&response)?,
171 OutputFormat::Text | OutputFormat::Markdown => {
172 output::emit_text(&format!(
173 "{}: {} --[{}]--> {} [{}]",
174 action, response.from, response.relation, response.to, response.namespace
175 ));
176 }
177 }
178
179 Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn link_response_without_redundant_aliases() {
188 let resp = LinkResponse {
190 action: "created".to_string(),
191 from: "entity-a".to_string(),
192 to: "entity-b".to_string(),
193 relation: "uses".to_string(),
194 weight: 1.0,
195 namespace: "default".to_string(),
196 elapsed_ms: 0,
197 created_entities: vec![],
198 };
199 let json = serde_json::to_value(&resp).expect("serialization must work");
200 assert_eq!(json["from"], "entity-a");
201 assert_eq!(json["to"], "entity-b");
202 assert!(
203 json.get("source").is_none(),
204 "field 'source' was removed in P1-O"
205 );
206 assert!(
207 json.get("target").is_none(),
208 "field 'target' was removed in P1-O"
209 );
210 }
211
212 #[test]
213 fn link_response_serializes_all_fields() {
214 let resp = LinkResponse {
215 action: "already_exists".to_string(),
216 from: "origin".to_string(),
217 to: "destination".to_string(),
218 relation: "mentions".to_string(),
219 weight: 0.8,
220 namespace: "test".to_string(),
221 elapsed_ms: 5,
222 created_entities: vec![],
223 };
224 let json = serde_json::to_value(&resp).expect("serialization must work");
225 assert!(json.get("action").is_some());
226 assert!(json.get("from").is_some());
227 assert!(json.get("to").is_some());
228 assert!(json.get("relation").is_some());
229 assert!(json.get("weight").is_some());
230 assert!(json.get("namespace").is_some());
231 assert!(json.get("elapsed_ms").is_some());
232 }
233
234 #[test]
235 fn link_response_omits_created_entities_when_empty() {
236 let resp = LinkResponse {
237 action: "created".to_string(),
238 from: "a".to_string(),
239 to: "b".to_string(),
240 relation: "uses".to_string(),
241 weight: 1.0,
242 namespace: "global".to_string(),
243 elapsed_ms: 0,
244 created_entities: vec![],
245 };
246 let json = serde_json::to_value(&resp).expect("serialization");
247 assert!(
248 json.get("created_entities").is_none(),
249 "empty vec must be omitted"
250 );
251 }
252
253 #[test]
254 fn link_response_includes_created_entities_when_present() {
255 let resp = LinkResponse {
256 action: "created".to_string(),
257 from: "new-a".to_string(),
258 to: "new-b".to_string(),
259 relation: "depends-on".to_string(),
260 weight: 0.5,
261 namespace: "test".to_string(),
262 elapsed_ms: 1,
263 created_entities: vec!["new-a".to_string(), "new-b".to_string()],
264 };
265 let json = serde_json::to_value(&resp).expect("serialization");
266 let created = json["created_entities"].as_array().expect("must be array");
267 assert_eq!(created.len(), 2);
268 assert_eq!(created[0], "new-a");
269 assert_eq!(created[1], "new-b");
270 }
271}