Skip to main content

sqlite_graphrag/commands/
link.rs

1//! Handler for the `link` CLI subcommand.
2
3use 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 (curated via `remember --graph-stdin` or created by `enrich`)\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  \
29LOCK WAITING:\n  \
30    The root-level --wait-lock SECONDS flag (default 30s) controls how long\n  \
31    the link/unlink subcommands wait for the global CLI lock before failing\n  \
32    with exit 15. In a cold start (first call in a new namespace), the lock\n  \
33    acquisition may exceed the default wait. CI pipelines should pass\n  \
34    --wait-lock 60 for headroom. The link command emits a tracing::info!\n  \
35    diagnostic when the wait exceeds 5 seconds so operators can correlate\n  \
36    cold-start latency with this CLI invocation.\n\n  \
37    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
38    Use --from-id / --to-id when you only have numeric entity IDs (v1.1.05).\n  \
39    Purely numeric names are rejected so --create-missing cannot spawn ghosts.\n  \
40    Memory names are managed via remember/read/edit/forget; entities are curated via\n  \
41    remember --graph-stdin, created by enrich, or auto-created via --create-missing.")]
42pub struct LinkArgs {
43    /// Source ENTITY name (graph node, not memory). Entities are curated via
44    /// `remember --graph-stdin`, created by `enrich`, or auto-created via `--create-missing`.
45    /// Use `graph entities` to list available entity names. Also accepts the alias `--name`.
46    /// Conflicts with `--from-id`.
47    #[arg(
48        long,
49        alias = "name",
50        required_unless_present = "from_id",
51        conflicts_with = "from_id"
52    )]
53    pub from: Option<String>,
54    /// Source entity ID (unambiguous alternative to `--from`). Conflicts with `--from`.
55    #[arg(long, value_name = "ID")]
56    pub from_id: Option<i64>,
57    /// Target ENTITY name (graph node, not memory). See `--from` for sourcing entity names.
58    /// Conflicts with `--to-id`.
59    #[arg(long, required_unless_present = "to_id", conflicts_with = "to_id")]
60    pub to: Option<String>,
61    /// Target entity ID (unambiguous alternative to `--to`). Conflicts with `--to`.
62    #[arg(long, value_name = "ID")]
63    pub to_id: Option<i64>,
64    /// Relation type between entities. Canonical values: applies-to, uses,
65    /// depends-on, causes, fixes, contradicts, supports, follows, related,
66    /// mentions, replaces, tracked-in. Any kebab-case or snake_case string
67    /// is also accepted as a custom relation.
68    #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
69    pub relation: String,
70    #[arg(long)]
71    pub weight: Option<f64>,
72    #[arg(long)]
73    pub namespace: Option<String>,
74    #[arg(long, value_enum, default_value = "json")]
75    pub format: OutputFormat,
76    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
77    pub json: bool,
78    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
79    pub db: Option<String>,
80    /// Auto-create entities when they do not exist. Created entities default to
81    /// type `concept` unless `--entity-type` specifies a different type.
82    #[arg(long, default_value_t = false)]
83    pub create_missing: bool,
84    /// Entity type assigned to auto-created entities (only effective with `--create-missing`).
85    #[arg(long, value_enum, default_value = "concept")]
86    pub entity_type: EntityType,
87    /// Reject non-canonical relation types with exit 1.
88    ///
89    /// When set, any relation not in the canonical list causes an immediate error.
90    /// Canonical values: applies-to, uses, depends-on, causes, fixes, contradicts,
91    /// supports, follows, related, mentions, replaces, tracked-in.
92    #[arg(
93        long,
94        default_value_t = false,
95        help = "Reject non-canonical relation types with exit 1"
96    )]
97    pub strict_relations: bool,
98}
99
100#[derive(Serialize)]
101struct LinkResponse {
102    action: String,
103    from: String,
104    to: String,
105    relation: String,
106    weight: f64,
107    namespace: String,
108    /// Total execution time in milliseconds from handler start to serialisation.
109    elapsed_ms: u64,
110    /// Entity names that were auto-created by `--create-missing`.
111    #[serde(skip_serializing_if = "Vec::is_empty")]
112    created_entities: Vec<String>,
113    /// Non-fatal warnings (e.g. non-canonical relation type).
114    #[serde(skip_serializing_if = "Vec::is_empty")]
115    warnings: Vec<String>,
116}
117
118pub fn run(args: LinkArgs) -> Result<(), AppError> {
119    let inicio = std::time::Instant::now();
120    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
121    let paths = AppPaths::resolve(args.db.as_deref())?;
122
123    crate::storage::connection::ensure_db_ready(&paths)?;
124
125    let mut conn = open_rw(&paths.db)?;
126
127    // v1.1.05 Bug 5: resolve by name OR by explicit --from-id / --to-id.
128    // Name path still validates BEFORE normalization (BUG-13 ALL_CAPS guard)
129    // and rejects purely numeric names so --create-missing cannot spawn ghosts.
130    let (norm_from, source_id_pre, from_is_id) = match (args.from_id, args.from.as_ref()) {
131        (Some(id), _) => {
132            let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
133            (name, Some(id), true)
134        }
135        (None, Some(from_name)) => {
136            if let Err(msg) = crate::storage::entities::validate_entity_name(from_name) {
137                return Err(AppError::Validation(msg.to_string()));
138            }
139            let norm = crate::parsers::normalize_entity_name(from_name);
140            (norm, None, false)
141        }
142        (None, None) => {
143            return Err(AppError::Validation(
144                "--from or --from-id is required".to_string(),
145            ));
146        }
147    };
148
149    let (norm_to, target_id_pre, to_is_id) = match (args.to_id, args.to.as_ref()) {
150        (Some(id), _) => {
151            let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
152            (name, Some(id), true)
153        }
154        (None, Some(to_name)) => {
155            if let Err(msg) = crate::storage::entities::validate_entity_name(to_name) {
156                return Err(AppError::Validation(msg.to_string()));
157            }
158            let norm = crate::parsers::normalize_entity_name(to_name);
159            (norm, None, false)
160        }
161        (None, None) => {
162            return Err(AppError::Validation(
163                "--to or --to-id is required".to_string(),
164            ));
165        }
166    };
167
168    tracing::debug!(
169        target: "link",
170        from = %norm_from,
171        to = %norm_to,
172        relation = %args.relation,
173        "creating relationship"
174    );
175
176    if norm_from == norm_to {
177        return Err(AppError::Validation(validation::self_referential_link()));
178    }
179    if let (Some(a), Some(b)) = (source_id_pre, target_id_pre) {
180        if a == b {
181            return Err(AppError::Validation(validation::self_referential_link()));
182        }
183    }
184
185    let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
186    if !(0.0..=1.0).contains(&weight) {
187        return Err(AppError::Validation(validation::invalid_link_weight(
188            weight,
189        )));
190    }
191    if weight >= 0.95 {
192        tracing::warn!(target: "link",
193            weight = weight,
194            "weight >= 0.95 compresses the scoring range; consider using a value below 0.95"
195        );
196    }
197    if weight <= 0.05 {
198        tracing::warn!(target: "link",
199            weight = weight,
200            "weight <= 0.05 may be too weak to influence traversal; consider using a value above 0.05"
201        );
202    }
203
204    let mut warnings: Vec<String> = Vec::with_capacity(2);
205    let is_canonical = crate::parsers::is_canonical_relation(&args.relation);
206    if !is_canonical {
207        if args.strict_relations {
208            return Err(AppError::Validation(format!(
209                "non-canonical relation '{}': use --strict-relations=false or choose from: {}",
210                args.relation,
211                crate::parsers::CANONICAL_RELATIONS.join(", ")
212            )));
213        }
214        warnings.push(format!("non-canonical relation '{}'", args.relation));
215        tracing::warn!(target: "link",
216            relation = %args.relation,
217            "non-canonical relation accepted; consider using a well-known value"
218        );
219    }
220    let relation_str = &args.relation;
221
222    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
223
224    let mut created_entities: Vec<String> = Vec::with_capacity(2);
225
226    if args.entity_type.as_str() == "memory" {
227        tracing::warn!(target: "link",
228            entity_type = "memory",
229            "entity_type 'memory' may conflict with memory table semantics; consider using 'concept' or another type"
230        );
231    }
232
233    // ID path never creates missing entities — IDs must already exist.
234    let source_id = if let Some(id) = source_id_pre {
235        let _ = from_is_id;
236        id
237    } else {
238        match entities::find_entity_id(&tx, &namespace, &norm_from)? {
239            Some(id) => id,
240            None if args.create_missing => {
241                let new_entity = NewEntity {
242                    name: norm_from.clone(),
243                    entity_type: args.entity_type,
244                    description: None,
245                };
246                created_entities.push(norm_from.clone());
247                entities::upsert_entity(&tx, &namespace, &new_entity)?
248            }
249            None => {
250                return Err(AppError::NotFound(errors_msg::entity_not_found(
251                    &norm_from, &namespace,
252                )));
253            }
254        }
255    };
256
257    let target_id = if let Some(id) = target_id_pre {
258        let _ = to_is_id;
259        id
260    } else {
261        match entities::find_entity_id(&tx, &namespace, &norm_to)? {
262            Some(id) => id,
263            None if args.create_missing => {
264                let new_entity = NewEntity {
265                    name: norm_to.clone(),
266                    entity_type: args.entity_type,
267                    description: None,
268                };
269                created_entities.push(norm_to.clone());
270                entities::upsert_entity(&tx, &namespace, &new_entity)?
271            }
272            None => {
273                return Err(AppError::NotFound(errors_msg::entity_not_found(
274                    &norm_to, &namespace,
275                )));
276            }
277        }
278    };
279
280    let (rel_id, was_created) = entities::create_or_fetch_relationship(
281        &tx,
282        &namespace,
283        source_id,
284        target_id,
285        relation_str,
286        weight,
287        None,
288    )?;
289
290    let actual_weight: f64 = tx.query_row(
291        "SELECT weight FROM relationships WHERE id = ?1",
292        params![rel_id],
293        |r| r.get(0),
294    )?;
295
296    if was_created {
297        entities::recalculate_degree(&tx, source_id)?;
298        entities::recalculate_degree(&tx, target_id)?;
299    }
300    tx.commit()?;
301
302    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
303
304    let action = if was_created {
305        "created".to_string()
306    } else {
307        "already_exists".to_string()
308    };
309
310    let response = LinkResponse {
311        action: action.clone(),
312        from: norm_from.clone(),
313        to: norm_to.clone(),
314        relation: relation_str.to_string(),
315        weight: actual_weight,
316        namespace: namespace.clone(),
317        elapsed_ms: inicio.elapsed().as_millis() as u64,
318        created_entities,
319        warnings,
320    };
321
322    match args.format {
323        OutputFormat::Json => output::emit_json(&response)?,
324        OutputFormat::Text | OutputFormat::Markdown => {
325            output::emit_text(&format!(
326                "{}: {} --[{}]--> {} [{}]",
327                action, response.from, response.relation, response.to, response.namespace
328            ));
329        }
330    }
331
332    Ok(())
333}
334
335/// Resolve entity id → (name, namespace) enforcing namespace membership.
336fn resolve_entity_name_by_id(
337    conn: &rusqlite::Connection,
338    namespace: &str,
339    id: i64,
340) -> Result<(String, String), AppError> {
341    let mut stmt = conn
342        .prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2")?;
343    match stmt.query_row(params![id, namespace], |r| {
344        Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
345    }) {
346        Ok(row) => Ok(row),
347        Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
348            "entity id={id} not found in namespace '{namespace}'"
349        ))),
350        Err(e) => Err(AppError::Database(e)),
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[derive(clap::Parser)]
359    struct TestCli {
360        #[command(flatten)]
361        args: LinkArgs,
362    }
363
364    #[test]
365    fn clap_accepts_from_id_to_id() {
366        use clap::Parser;
367        let ok = match TestCli::try_parse_from([
368            "t",
369            "--from-id",
370            "1",
371            "--to-id",
372            "2",
373            "--relation",
374            "supports",
375        ]) {
376            Ok(v) => v,
377            Err(e) => panic!("from-id/to-id must parse: {e}"),
378        };
379        assert_eq!(ok.args.from_id, Some(1));
380        assert_eq!(ok.args.to_id, Some(2));
381    }
382
383    #[test]
384    fn clap_rejects_from_combined_with_from_id() {
385        use clap::Parser;
386        match TestCli::try_parse_from([
387            "t",
388            "--from",
389            "a",
390            "--from-id",
391            "1",
392            "--to",
393            "b",
394            "--relation",
395            "supports",
396        ]) {
397            Ok(_) => panic!("expected argument conflict"),
398            Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict),
399        }
400    }
401
402    #[test]
403    fn link_rejects_purely_numeric_name_validation() {
404        // Unit-level: validate_entity_name rejects digit-only names.
405        assert!(crate::storage::entities::validate_entity_name("89975").is_err());
406    }
407
408    #[test]
409    fn link_response_without_redundant_aliases() {
410        // P1-O: source/target fields were removed from the JSON response.
411        let resp = LinkResponse {
412            action: "created".to_string(),
413            from: "entity-a".to_string(),
414            to: "entity-b".to_string(),
415            relation: "uses".to_string(),
416            weight: 1.0,
417            namespace: "default".to_string(),
418            elapsed_ms: 0,
419            created_entities: vec![],
420            warnings: vec![],
421        };
422        let json = serde_json::to_value(&resp).expect("serialization must work");
423        assert_eq!(json["from"], "entity-a");
424        assert_eq!(json["to"], "entity-b");
425        assert!(
426            json.get("source").is_none(),
427            "field 'source' was removed in P1-O"
428        );
429        assert!(
430            json.get("target").is_none(),
431            "field 'target' was removed in P1-O"
432        );
433    }
434
435    #[test]
436    fn link_response_serializes_all_fields() {
437        let resp = LinkResponse {
438            action: "already_exists".to_string(),
439            from: "origin".to_string(),
440            to: "destination".to_string(),
441            relation: "mentions".to_string(),
442            weight: 0.8,
443            namespace: "test".to_string(),
444            elapsed_ms: 5,
445            created_entities: vec![],
446            warnings: vec![],
447        };
448        let json = serde_json::to_value(&resp).expect("serialization must work");
449        assert!(json.get("action").is_some());
450        assert!(json.get("from").is_some());
451        assert!(json.get("to").is_some());
452        assert!(json.get("relation").is_some());
453        assert!(json.get("weight").is_some());
454        assert!(json.get("namespace").is_some());
455        assert!(json.get("elapsed_ms").is_some());
456    }
457
458    #[test]
459    fn link_response_omits_created_entities_when_empty() {
460        let resp = LinkResponse {
461            action: "created".to_string(),
462            from: "a".to_string(),
463            to: "b".to_string(),
464            relation: "uses".to_string(),
465            weight: 1.0,
466            namespace: "global".to_string(),
467            elapsed_ms: 0,
468            created_entities: vec![],
469            warnings: vec![],
470        };
471        let json = serde_json::to_value(&resp).expect("serialization");
472        assert!(
473            json.get("created_entities").is_none(),
474            "empty vec must be omitted"
475        );
476    }
477
478    #[test]
479    fn link_response_includes_created_entities_when_present() {
480        let resp = LinkResponse {
481            action: "created".to_string(),
482            from: "new-a".to_string(),
483            to: "new-b".to_string(),
484            relation: "depends-on".to_string(),
485            weight: 0.5,
486            namespace: "test".to_string(),
487            elapsed_ms: 1,
488            created_entities: vec!["new-a".to_string(), "new-b".to_string()],
489            warnings: vec![],
490        };
491        let json = serde_json::to_value(&resp).expect("serialization");
492        let created = json["created_entities"].as_array().expect("must be array");
493        assert_eq!(created.len(), 2);
494        assert_eq!(created[0], "new-a");
495        assert_eq!(created[1], "new-b");
496    }
497
498    #[test]
499    fn link_response_includes_warnings_when_non_canonical() {
500        let resp = LinkResponse {
501            action: "created".to_string(),
502            from: "a".to_string(),
503            to: "b".to_string(),
504            relation: "implements".to_string(),
505            weight: 0.5,
506            namespace: "global".to_string(),
507            elapsed_ms: 0,
508            created_entities: vec![],
509            warnings: vec!["non-canonical relation 'implements'".to_string()],
510        };
511        let json = serde_json::to_value(&resp).expect("serialization");
512        let w = json["warnings"]
513            .as_array()
514            .expect("warnings must be present");
515        assert_eq!(w.len(), 1);
516        assert!(w[0].as_str().unwrap().contains("implements"));
517    }
518
519    #[test]
520    fn link_response_omits_warnings_when_empty() {
521        let resp = LinkResponse {
522            action: "created".to_string(),
523            from: "a".to_string(),
524            to: "b".to_string(),
525            relation: "uses".to_string(),
526            weight: 0.5,
527            namespace: "global".to_string(),
528            elapsed_ms: 0,
529            created_entities: vec![],
530            warnings: vec![],
531        };
532        let json = serde_json::to_value(&resp).expect("serialization");
533        assert!(
534            json.get("warnings").is_none(),
535            "empty warnings must be omitted"
536        );
537    }
538}