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 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    /// Source ENTITY name (graph node, not memory). Entities are extracted by GLiNER NER during
33    /// `remember` or auto-created via `--create-missing`. Use `graph entities` to list
34    /// available entity names. Also accepts the alias `--name`.
35    #[arg(long, alias = "name")]
36    pub from: String,
37    /// Target ENTITY name (graph node, not memory). See `--from` for sourcing entity names.
38    #[arg(long)]
39    pub to: String,
40    /// Relation type between entities. Canonical values: applies-to, uses,
41    /// depends-on, causes, fixes, contradicts, supports, follows, related,
42    /// mentions, replaces, tracked-in. Any kebab-case or snake_case string
43    /// is also accepted as a custom relation.
44    #[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    /// Auto-create entities when they do not exist. Created entities default to
57    /// type `concept` unless `--entity-type` specifies a different type.
58    #[arg(long, default_value_t = false)]
59    pub create_missing: bool,
60    /// Entity type assigned to auto-created entities (only effective with `--create-missing`).
61    #[arg(long, value_enum, default_value = "concept")]
62    pub entity_type: EntityType,
63    /// Reject non-canonical relation types with exit 1.
64    ///
65    /// When set, any relation not in the canonical list causes an immediate error.
66    /// Canonical values: applies-to, uses, depends-on, causes, fixes, contradicts,
67    /// supports, follows, related, mentions, replaces, tracked-in.
68    #[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    /// Total execution time in milliseconds from handler start to serialisation.
85    elapsed_ms: u64,
86    /// Entity names that were auto-created by `--create-missing`.
87    #[serde(skip_serializing_if = "Vec::is_empty")]
88    created_entities: Vec<String>,
89    /// Non-fatal warnings (e.g. non-canonical relation type).
90    #[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        // P1-O: source/target fields were removed from the JSON response.
246        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}