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