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
62    let mut conn = open_rw(&paths.db)?;
63
64    let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
65        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
66    let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
67        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
68
69    let rel = entities::find_relationship(&conn, source_id, target_id, relation_str)?.ok_or_else(
70        || {
71            AppError::NotFound(errors_msg::relationship_not_found(
72                &args.from,
73                relation_str,
74                &args.to,
75                &namespace,
76            ))
77        },
78    )?;
79
80    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
81    entities::delete_relationship_by_id(&tx, rel.id)?;
82    entities::recalculate_degree(&tx, source_id)?;
83    entities::recalculate_degree(&tx, target_id)?;
84    tx.commit()?;
85
86    let response = UnlinkResponse {
87        action: "deleted".to_string(),
88        relationship_id: rel.id,
89        from_name: args.from.clone(),
90        to_name: args.to.clone(),
91        relation: relation_str.to_string(),
92        namespace: namespace.clone(),
93        elapsed_ms: inicio.elapsed().as_millis() as u64,
94    };
95
96    match args.format {
97        OutputFormat::Json => output::emit_json(&response)?,
98        OutputFormat::Text | OutputFormat::Markdown => {
99            output::emit_text(&format!(
100                "deleted: {} --[{}]--> {} [{}]",
101                response.from_name, response.relation, response.to_name, response.namespace
102            ));
103        }
104    }
105
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn unlink_response_serializes_all_fields() {
115        let resp = UnlinkResponse {
116            action: "deleted".to_string(),
117            relationship_id: 99,
118            from_name: "entity-a".to_string(),
119            to_name: "entity-b".to_string(),
120            relation: "uses".to_string(),
121            namespace: "global".to_string(),
122            elapsed_ms: 5,
123        };
124        let json = serde_json::to_value(&resp).expect("serialization failed");
125        assert_eq!(json["action"], "deleted");
126        assert_eq!(json["relationship_id"], 99i64);
127        assert_eq!(json["from_name"], "entity-a");
128        assert_eq!(json["to_name"], "entity-b");
129        assert_eq!(json["relation"], "uses");
130        assert_eq!(json["namespace"], "global");
131        assert_eq!(json["elapsed_ms"], 5u64);
132    }
133
134    #[test]
135    fn unlink_response_action_must_be_deleted() {
136        let resp = UnlinkResponse {
137            action: "deleted".to_string(),
138            relationship_id: 1,
139            from_name: "a".to_string(),
140            to_name: "b".to_string(),
141            relation: "related".to_string(),
142            namespace: "global".to_string(),
143            elapsed_ms: 0,
144        };
145        let json = serde_json::to_value(&resp).expect("serialization failed");
146        assert_eq!(
147            json["action"], "deleted",
148            "unlink action must always be 'deleted'"
149        );
150    }
151
152    #[test]
153    fn unlink_response_relationship_id_positive() {
154        let resp = UnlinkResponse {
155            action: "deleted".to_string(),
156            relationship_id: 42,
157            from_name: "origin".to_string(),
158            to_name: "destination".to_string(),
159            relation: "supports".to_string(),
160            namespace: "project".to_string(),
161            elapsed_ms: 3,
162        };
163        let json = serde_json::to_value(&resp).expect("serialization failed");
164        assert!(
165            json["relationship_id"].as_i64().unwrap() > 0,
166            "relationship_id must be positive after unlink"
167        );
168    }
169}