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    let response = UnlinkResponse {
88        action: "deleted".to_string(),
89        relationship_id: rel.id,
90        from_name: args.from.clone(),
91        to_name: args.to.clone(),
92        relation: relation_str.to_string(),
93        namespace: namespace.clone(),
94        elapsed_ms: inicio.elapsed().as_millis() as u64,
95    };
96
97    match args.format {
98        OutputFormat::Json => output::emit_json(&response)?,
99        OutputFormat::Text | OutputFormat::Markdown => {
100            output::emit_text(&format!(
101                "deleted: {} --[{}]--> {} [{}]",
102                response.from_name, response.relation, response.to_name, response.namespace
103            ));
104        }
105    }
106
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn unlink_response_serializes_all_fields() {
116        let resp = UnlinkResponse {
117            action: "deleted".to_string(),
118            relationship_id: 99,
119            from_name: "entity-a".to_string(),
120            to_name: "entity-b".to_string(),
121            relation: "uses".to_string(),
122            namespace: "global".to_string(),
123            elapsed_ms: 5,
124        };
125        let json = serde_json::to_value(&resp).expect("serialization failed");
126        assert_eq!(json["action"], "deleted");
127        assert_eq!(json["relationship_id"], 99i64);
128        assert_eq!(json["from_name"], "entity-a");
129        assert_eq!(json["to_name"], "entity-b");
130        assert_eq!(json["relation"], "uses");
131        assert_eq!(json["namespace"], "global");
132        assert_eq!(json["elapsed_ms"], 5u64);
133    }
134
135    #[test]
136    fn unlink_response_action_must_be_deleted() {
137        let resp = UnlinkResponse {
138            action: "deleted".to_string(),
139            relationship_id: 1,
140            from_name: "a".to_string(),
141            to_name: "b".to_string(),
142            relation: "related".to_string(),
143            namespace: "global".to_string(),
144            elapsed_ms: 0,
145        };
146        let json = serde_json::to_value(&resp).expect("serialization failed");
147        assert_eq!(
148            json["action"], "deleted",
149            "unlink action must always be 'deleted'"
150        );
151    }
152
153    #[test]
154    fn unlink_response_relationship_id_positive() {
155        let resp = UnlinkResponse {
156            action: "deleted".to_string(),
157            relationship_id: 42,
158            from_name: "origin".to_string(),
159            to_name: "destination".to_string(),
160            relation: "supports".to_string(),
161            namespace: "project".to_string(),
162            elapsed_ms: 3,
163        };
164        let json = serde_json::to_value(&resp).expect("serialization failed");
165        assert!(
166            json["relationship_id"].as_i64().unwrap() > 0,
167            "relationship_id must be positive after unlink"
168        );
169    }
170}