Skip to main content

sqlite_graphrag/commands/
unlink.rs

1//! Handler for the `unlink` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output::{self, OutputFormat};
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::entities;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Remove a relationship between two existing graph entities\n  \
14    sqlite-graphrag unlink --from oauth-flow --to refresh-tokens --relation related\n\n  \
15    # If either entity or the relationship does not exist, the command exits with code 4.\n\n  \
16NOTE:\n  \
17    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
18    To inspect current entities and relationships, run: sqlite-graphrag graph --format json")]
19pub struct UnlinkArgs {
20    /// Source ENTITY name (graph node, not memory). Also accepts the aliases `--source` and `--name`.
21    /// To list current entities run `graph --format json | jaq '.nodes[].name'`.
22    #[arg(long, alias = "source", alias = "name")]
23    pub from: String,
24    /// Target ENTITY name (graph node, not memory). Also accepts the alias `--target`.
25    #[arg(long, alias = "target")]
26    pub to: String,
27    /// Relation type to remove. Accepts canonical values (e.g. uses, depends-on)
28    /// or any custom snake_case/kebab-case string.
29    #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
30    pub relation: String,
31    #[arg(long)]
32    pub namespace: Option<String>,
33    #[arg(long, value_enum, default_value = "json")]
34    pub format: OutputFormat,
35    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
36    pub json: bool,
37    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
38    pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct UnlinkResponse {
43    action: String,
44    relationship_id: i64,
45    from_name: String,
46    to_name: String,
47    relation: String,
48    namespace: String,
49    /// Total execution time in milliseconds from handler start to serialisation.
50    elapsed_ms: u64,
51}
52
53pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
54    let inicio = std::time::Instant::now();
55    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
56    let paths = AppPaths::resolve(args.db.as_deref())?;
57
58    crate::storage::connection::ensure_db_ready(&paths)?;
59
60    let relation_str = &args.relation;
61    crate::parsers::warn_if_non_canonical(relation_str);
62
63    let mut conn = open_rw(&paths.db)?;
64
65    let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
66        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
67    let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
68        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
69
70    let rel = entities::find_relationship(&conn, source_id, target_id, relation_str)?.ok_or_else(
71        || {
72            AppError::NotFound(errors_msg::relationship_not_found(
73                &args.from,
74                relation_str,
75                &args.to,
76                &namespace,
77            ))
78        },
79    )?;
80
81    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
82    entities::delete_relationship_by_id(&tx, rel.id)?;
83    entities::recalculate_degree(&tx, source_id)?;
84    entities::recalculate_degree(&tx, target_id)?;
85    tx.commit()?;
86
87    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
88
89    let response = UnlinkResponse {
90        action: "deleted".to_string(),
91        relationship_id: rel.id,
92        from_name: args.from.clone(),
93        to_name: args.to.clone(),
94        relation: relation_str.to_string(),
95        namespace: namespace.clone(),
96        elapsed_ms: inicio.elapsed().as_millis() as u64,
97    };
98
99    match args.format {
100        OutputFormat::Json => output::emit_json(&response)?,
101        OutputFormat::Text | OutputFormat::Markdown => {
102            output::emit_text(&format!(
103                "deleted: {} --[{}]--> {} [{}]",
104                response.from_name, response.relation, response.to_name, response.namespace
105            ));
106        }
107    }
108
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn unlink_response_serializes_all_fields() {
118        let resp = UnlinkResponse {
119            action: "deleted".to_string(),
120            relationship_id: 99,
121            from_name: "entity-a".to_string(),
122            to_name: "entity-b".to_string(),
123            relation: "uses".to_string(),
124            namespace: "global".to_string(),
125            elapsed_ms: 5,
126        };
127        let json = serde_json::to_value(&resp).expect("serialization failed");
128        assert_eq!(json["action"], "deleted");
129        assert_eq!(json["relationship_id"], 99i64);
130        assert_eq!(json["from_name"], "entity-a");
131        assert_eq!(json["to_name"], "entity-b");
132        assert_eq!(json["relation"], "uses");
133        assert_eq!(json["namespace"], "global");
134        assert_eq!(json["elapsed_ms"], 5u64);
135    }
136
137    #[test]
138    fn unlink_response_action_must_be_deleted() {
139        let resp = UnlinkResponse {
140            action: "deleted".to_string(),
141            relationship_id: 1,
142            from_name: "a".to_string(),
143            to_name: "b".to_string(),
144            relation: "related".to_string(),
145            namespace: "global".to_string(),
146            elapsed_ms: 0,
147        };
148        let json = serde_json::to_value(&resp).expect("serialization failed");
149        assert_eq!(
150            json["action"], "deleted",
151            "unlink action must always be 'deleted'"
152        );
153    }
154
155    #[test]
156    fn unlink_response_relationship_id_positive() {
157        let resp = UnlinkResponse {
158            action: "deleted".to_string(),
159            relationship_id: 42,
160            from_name: "origin".to_string(),
161            to_name: "destination".to_string(),
162            relation: "supports".to_string(),
163            namespace: "project".to_string(),
164            elapsed_ms: 3,
165        };
166        let json = serde_json::to_value(&resp).expect("serialization failed");
167        assert!(
168            json["relationship_id"].as_i64().unwrap() > 0,
169            "relationship_id must be positive after unlink"
170        );
171    }
172}