sqlite_graphrag/commands/
link.rs1use crate::constants::DEFAULT_RELATION_WEIGHT;
4use crate::entity_type::EntityType;
5use crate::errors::AppError;
6use crate::i18n::{errors_msg, validation};
7use crate::output::{self, OutputFormat};
8use crate::paths::AppPaths;
9use crate::storage::connection::open_rw;
10use crate::storage::entities;
11use crate::storage::entities::NewEntity;
12use serde::Serialize;
13
14#[derive(clap::Args)]
15#[command(after_long_help = "EXAMPLES:\n \
16 # Link two existing graph entities (extracted by GLiNER NER during `remember`)\n \
17 sqlite-graphrag link --from oauth-flow --to refresh-tokens --relation related\n\n \
18 # Auto-create entities that don't exist yet\n \
19 sqlite-graphrag link --from concept-a --to concept-b --relation depends-on --create-missing\n\n \
20 # Specify entity type for auto-created entities\n \
21 sqlite-graphrag link --from alice --to acme-corp --relation related --create-missing --entity-type person\n\n \
22 # Use a custom (non-canonical) relation type\n \
23 sqlite-graphrag link --from module-a --to module-b --relation implements --create-missing\n\n \
24 # If the entity does not exist and --create-missing is not set, the command fails with exit 4.\n \
25 # To list current entity names:\n \
26 sqlite-graphrag graph entities | jaq '.entities[].name'\n\n \
27NOTE:\n \
28 --from and --to expect ENTITY names (graph nodes), not memory names.\n \
29 Memory names are managed via remember/read/edit/forget; entities are auto-extracted\n \
30 by GLiNER NER from memory bodies or auto-created via --create-missing.")]
31pub struct LinkArgs {
32 #[arg(long, alias = "name")]
36 pub from: String,
37 #[arg(long)]
39 pub to: String,
40 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
45 pub relation: String,
46 #[arg(long)]
47 pub weight: Option<f64>,
48 #[arg(long)]
49 pub namespace: Option<String>,
50 #[arg(long, value_enum, default_value = "json")]
51 pub format: OutputFormat,
52 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
53 pub json: bool,
54 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
55 pub db: Option<String>,
56 #[arg(long, default_value_t = false)]
59 pub create_missing: bool,
60 #[arg(long, value_enum, default_value = "concept")]
62 pub entity_type: EntityType,
63 #[arg(
69 long,
70 default_value_t = false,
71 help = "Reject non-canonical relation types with exit 1"
72 )]
73 pub strict_relations: bool,
74}
75
76#[derive(Serialize)]
77struct LinkResponse {
78 action: String,
79 from: String,
80 to: String,
81 relation: String,
82 weight: f64,
83 namespace: String,
84 elapsed_ms: u64,
86 #[serde(skip_serializing_if = "Vec::is_empty")]
88 created_entities: Vec<String>,
89 #[serde(skip_serializing_if = "Vec::is_empty")]
91 warnings: Vec<String>,
92}
93
94pub fn run(args: LinkArgs) -> Result<(), AppError> {
95 let inicio = std::time::Instant::now();
96 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
97 let paths = AppPaths::resolve(args.db.as_deref())?;
98
99 if args.from == args.to {
100 return Err(AppError::Validation(validation::self_referential_link()));
101 }
102
103 let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
104 if !(0.0..=1.0).contains(&weight) {
105 return Err(AppError::Validation(validation::invalid_link_weight(
106 weight,
107 )));
108 }
109 if weight >= 0.95 {
110 tracing::warn!(
111 weight = weight,
112 "weight >= 0.95 compresses the scoring range; consider using a value below 0.95"
113 );
114 }
115 if weight <= 0.05 {
116 tracing::warn!(
117 weight = weight,
118 "weight <= 0.05 may be too weak to influence traversal; consider using a value above 0.05"
119 );
120 }
121
122 crate::storage::connection::ensure_db_ready(&paths)?;
123
124 let mut warnings: Vec<String> = Vec::new();
125 let is_canonical = crate::parsers::is_canonical_relation(&args.relation);
126 if !is_canonical {
127 if args.strict_relations {
128 return Err(AppError::Validation(format!(
129 "non-canonical relation '{}': use --strict-relations=false or choose from: {}",
130 args.relation,
131 crate::parsers::CANONICAL_RELATIONS.join(", ")
132 )));
133 }
134 warnings.push(format!("non-canonical relation '{}'", args.relation));
135 tracing::warn!(
136 relation = %args.relation,
137 "non-canonical relation accepted; consider using a well-known value"
138 );
139 }
140 let relation_str = &args.relation;
141
142 let mut conn = open_rw(&paths.db)?;
143 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
144
145 let mut created_entities: Vec<String> = Vec::with_capacity(2);
146
147 if args.entity_type.as_str() == "memory" {
148 tracing::warn!(
149 entity_type = "memory",
150 "entity_type 'memory' may conflict with memory table semantics; consider using 'concept' or another type"
151 );
152 }
153
154 let source_id = match entities::find_entity_id(&tx, &namespace, &args.from)? {
155 Some(id) => id,
156 None if args.create_missing => {
157 let new_entity = NewEntity {
158 name: args.from.clone(),
159 entity_type: args.entity_type,
160 description: None,
161 };
162 created_entities.push(args.from.clone());
163 entities::upsert_entity(&tx, &namespace, &new_entity)?
164 }
165 None => {
166 return Err(AppError::NotFound(errors_msg::entity_not_found(
167 &args.from, &namespace,
168 )));
169 }
170 };
171
172 let target_id = match entities::find_entity_id(&tx, &namespace, &args.to)? {
173 Some(id) => id,
174 None if args.create_missing => {
175 let new_entity = NewEntity {
176 name: args.to.clone(),
177 entity_type: args.entity_type,
178 description: None,
179 };
180 created_entities.push(args.to.clone());
181 entities::upsert_entity(&tx, &namespace, &new_entity)?
182 }
183 None => {
184 return Err(AppError::NotFound(errors_msg::entity_not_found(
185 &args.to, &namespace,
186 )));
187 }
188 };
189
190 let (_rel_id, was_created) = entities::create_or_fetch_relationship(
191 &tx,
192 &namespace,
193 source_id,
194 target_id,
195 relation_str,
196 weight,
197 None,
198 )?;
199
200 if was_created {
201 entities::recalculate_degree(&tx, source_id)?;
202 entities::recalculate_degree(&tx, target_id)?;
203 }
204 tx.commit()?;
205
206 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
207
208 let action = if was_created {
209 "created".to_string()
210 } else {
211 "already_exists".to_string()
212 };
213
214 let response = LinkResponse {
215 action: action.clone(),
216 from: args.from.clone(),
217 to: args.to.clone(),
218 relation: relation_str.to_string(),
219 weight,
220 namespace: namespace.clone(),
221 elapsed_ms: inicio.elapsed().as_millis() as u64,
222 created_entities,
223 warnings,
224 };
225
226 match args.format {
227 OutputFormat::Json => output::emit_json(&response)?,
228 OutputFormat::Text | OutputFormat::Markdown => {
229 output::emit_text(&format!(
230 "{}: {} --[{}]--> {} [{}]",
231 action, response.from, response.relation, response.to, response.namespace
232 ));
233 }
234 }
235
236 Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn link_response_without_redundant_aliases() {
245 let resp = LinkResponse {
247 action: "created".to_string(),
248 from: "entity-a".to_string(),
249 to: "entity-b".to_string(),
250 relation: "uses".to_string(),
251 weight: 1.0,
252 namespace: "default".to_string(),
253 elapsed_ms: 0,
254 created_entities: vec![],
255 warnings: vec![],
256 };
257 let json = serde_json::to_value(&resp).expect("serialization must work");
258 assert_eq!(json["from"], "entity-a");
259 assert_eq!(json["to"], "entity-b");
260 assert!(
261 json.get("source").is_none(),
262 "field 'source' was removed in P1-O"
263 );
264 assert!(
265 json.get("target").is_none(),
266 "field 'target' was removed in P1-O"
267 );
268 }
269
270 #[test]
271 fn link_response_serializes_all_fields() {
272 let resp = LinkResponse {
273 action: "already_exists".to_string(),
274 from: "origin".to_string(),
275 to: "destination".to_string(),
276 relation: "mentions".to_string(),
277 weight: 0.8,
278 namespace: "test".to_string(),
279 elapsed_ms: 5,
280 created_entities: vec![],
281 warnings: vec![],
282 };
283 let json = serde_json::to_value(&resp).expect("serialization must work");
284 assert!(json.get("action").is_some());
285 assert!(json.get("from").is_some());
286 assert!(json.get("to").is_some());
287 assert!(json.get("relation").is_some());
288 assert!(json.get("weight").is_some());
289 assert!(json.get("namespace").is_some());
290 assert!(json.get("elapsed_ms").is_some());
291 }
292
293 #[test]
294 fn link_response_omits_created_entities_when_empty() {
295 let resp = LinkResponse {
296 action: "created".to_string(),
297 from: "a".to_string(),
298 to: "b".to_string(),
299 relation: "uses".to_string(),
300 weight: 1.0,
301 namespace: "global".to_string(),
302 elapsed_ms: 0,
303 created_entities: vec![],
304 warnings: vec![],
305 };
306 let json = serde_json::to_value(&resp).expect("serialization");
307 assert!(
308 json.get("created_entities").is_none(),
309 "empty vec must be omitted"
310 );
311 }
312
313 #[test]
314 fn link_response_includes_created_entities_when_present() {
315 let resp = LinkResponse {
316 action: "created".to_string(),
317 from: "new-a".to_string(),
318 to: "new-b".to_string(),
319 relation: "depends-on".to_string(),
320 weight: 0.5,
321 namespace: "test".to_string(),
322 elapsed_ms: 1,
323 created_entities: vec!["new-a".to_string(), "new-b".to_string()],
324 warnings: vec![],
325 };
326 let json = serde_json::to_value(&resp).expect("serialization");
327 let created = json["created_entities"].as_array().expect("must be array");
328 assert_eq!(created.len(), 2);
329 assert_eq!(created[0], "new-a");
330 assert_eq!(created[1], "new-b");
331 }
332
333 #[test]
334 fn link_response_includes_warnings_when_non_canonical() {
335 let resp = LinkResponse {
336 action: "created".to_string(),
337 from: "a".to_string(),
338 to: "b".to_string(),
339 relation: "implements".to_string(),
340 weight: 0.5,
341 namespace: "global".to_string(),
342 elapsed_ms: 0,
343 created_entities: vec![],
344 warnings: vec!["non-canonical relation 'implements'".to_string()],
345 };
346 let json = serde_json::to_value(&resp).expect("serialization");
347 let w = json["warnings"]
348 .as_array()
349 .expect("warnings must be present");
350 assert_eq!(w.len(), 1);
351 assert!(w[0].as_str().unwrap().contains("implements"));
352 }
353
354 #[test]
355 fn link_response_omits_warnings_when_empty() {
356 let resp = LinkResponse {
357 action: "created".to_string(),
358 from: "a".to_string(),
359 to: "b".to_string(),
360 relation: "uses".to_string(),
361 weight: 0.5,
362 namespace: "global".to_string(),
363 elapsed_ms: 0,
364 created_entities: vec![],
365 warnings: vec![],
366 };
367 let json = serde_json::to_value(&resp).expect("serialization");
368 assert!(
369 json.get("warnings").is_none(),
370 "empty warnings must be omitted"
371 );
372 }
373}