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