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 specific relationship between two entities\n  \
14    sqlite-graphrag unlink --from oauth-flow --to refresh-tokens --relation related\n\n  \
15    # Remove ALL relationships between two entities (any relation type)\n  \
16    sqlite-graphrag unlink --from oauth-flow --to refresh-tokens\n\n  \
17    # Remove ALL relationships where an entity is source or target\n  \
18    sqlite-graphrag unlink --entity oauth-flow --all\n\n  \
19NOTE:\n  \
20    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
21    To inspect current entities and relationships, run: sqlite-graphrag graph --format json")]
22pub struct UnlinkArgs {
23    /// Source ENTITY name (graph node, not memory). Also accepts the aliases `--source` and `--name`.
24    /// To list current entities run `graph --format json | jaq '.nodes[].name'`.
25    #[arg(long, alias = "source", alias = "name", conflicts_with = "entity")]
26    pub from: Option<String>,
27    /// Target ENTITY name (graph node, not memory). Also accepts the alias `--target`.
28    #[arg(long, alias = "target", conflicts_with = "entity")]
29    pub to: Option<String>,
30    /// Relation type to remove. When omitted with --from/--to, ALL relationships between
31    /// those two entities are deleted. Accepts canonical values (e.g. uses, depends-on)
32    /// or any custom snake_case/kebab-case string.
33    #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
34    pub relation: Option<String>,
35    /// Entity name. Combine with --all to remove every relationship of that
36    /// entity, or with --memory to remove the curated memory↔entity binding.
37    #[arg(long, conflicts_with_all = ["from", "to"])]
38    pub entity: Option<String>,
39    /// When combined with --entity, removes ALL relationships where that entity is source or target.
40    #[arg(long, requires = "entity")]
41    pub all: bool,
42    /// GAP-SG-52: memory name. Combine with --entity to surgically remove the
43    /// curated `memory_entities` binding for that (memory, entity) pair —
44    /// covering bindings created via `remember --graph-stdin` that `prune-ner`
45    /// would not target selectively.
46    #[arg(long, requires = "entity", conflicts_with_all = ["from", "to", "all"], value_name = "NAME")]
47    pub memory: Option<String>,
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}
57
58#[derive(Serialize)]
59struct UnlinkResponse {
60    action: String,
61    from_name: String,
62    to_name: String,
63    relation: String,
64    relationships_removed: u64,
65    namespace: String,
66    /// Total execution time in milliseconds from handler start to serialisation.
67    elapsed_ms: u64,
68}
69
70pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
71    let inicio = std::time::Instant::now();
72    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
73    let paths = AppPaths::resolve(args.db.as_deref())?;
74
75    crate::storage::connection::ensure_db_ready(&paths)?;
76
77    if let Some(relation_str) = &args.relation {
78        crate::parsers::warn_if_non_canonical(relation_str);
79    }
80
81    let mut conn = open_rw(&paths.db)?;
82
83    // GAP-SG-52: --memory <name> --entity <name> → remove the curated
84    // memory↔entity binding for that pair (the `memory_entities` junction row).
85    if let Some(memory_name) = args.memory.as_deref() {
86        let entity_name = args.entity.as_deref().ok_or_else(|| {
87            AppError::Validation("--entity is required when --memory is used".to_string())
88        })?;
89        let memory_id = crate::storage::memories::find_by_name(&conn, &namespace, memory_name)?
90            .map(|(id, _, _)| id)
91            .ok_or_else(|| AppError::MemoryNotFound {
92                name: memory_name.to_string(),
93                namespace: namespace.clone(),
94            })?;
95        let entity_id =
96            entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
97                AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
98            })?;
99
100        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
101        let removed = entities::unlink_memory_entity(&tx, memory_id, entity_id)?;
102        entities::recalculate_degree(&tx, entity_id)?;
103        tx.commit()?;
104
105        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
106
107        let response = UnlinkResponse {
108            action: if removed > 0 {
109                "deleted".to_string()
110            } else {
111                "noop".to_string()
112            },
113            from_name: memory_name.to_string(),
114            to_name: entity_name.to_string(),
115            relation: "memory-entity".to_string(),
116            relationships_removed: removed,
117            namespace: namespace.clone(),
118            elapsed_ms: inicio.elapsed().as_millis() as u64,
119        };
120
121        match args.format {
122            OutputFormat::Json => output::emit_json(&response)?,
123            OutputFormat::Text | OutputFormat::Markdown => {
124                output::emit_text(&format!(
125                    "{}: memory '{}' --[memory-entity]--> entity '{}' removed {} binding(s) [{}]",
126                    response.action,
127                    response.from_name,
128                    response.to_name,
129                    response.relationships_removed,
130                    response.namespace
131                ));
132            }
133        }
134        return Ok(());
135    }
136
137    // --entity without --all or --memory is ambiguous: reject loudly.
138    if args.entity.is_some() && !args.all {
139        return Err(AppError::Validation(
140            "--entity must be combined with --all (remove all relationships) or --memory <name> (remove a memory↔entity binding)"
141                .to_string(),
142        ));
143    }
144
145    // Mode: --entity --all → delete every relationship for that entity.
146    if args.all {
147        let entity_name = args.entity.as_deref().unwrap_or("");
148        let entity_id =
149            entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
150                AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
151            })?;
152
153        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
154        let removed = delete_all_entity_relationships(&tx, entity_id)?;
155        entities::recalculate_degree(&tx, entity_id)?;
156        tx.commit()?;
157
158        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
159
160        let response = UnlinkResponse {
161            action: "deleted".to_string(),
162            from_name: entity_name.to_string(),
163            to_name: "*".to_string(),
164            relation: "*".to_string(),
165            relationships_removed: removed,
166            namespace: namespace.clone(),
167            elapsed_ms: inicio.elapsed().as_millis() as u64,
168        };
169
170        match args.format {
171            OutputFormat::Json => output::emit_json(&response)?,
172            OutputFormat::Text | OutputFormat::Markdown => {
173                output::emit_text(&format!(
174                    "deleted: {} --[*]--> * removed {} relationship(s) [{}]",
175                    response.from_name, response.relationships_removed, response.namespace
176                ));
177            }
178        }
179        return Ok(());
180    }
181
182    // Mode: --from/--to (with optional --relation).
183    let from_name = args.from.as_deref().ok_or_else(|| {
184        AppError::Validation("--from is required when --entity/--all is not used".to_string())
185    })?;
186    let to_name = args.to.as_deref().ok_or_else(|| {
187        AppError::Validation("--to is required when --entity/--all is not used".to_string())
188    })?;
189
190    let source_id = entities::find_entity_id(&conn, &namespace, from_name)?
191        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(from_name, &namespace)))?;
192    let target_id = entities::find_entity_id(&conn, &namespace, to_name)?
193        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(to_name, &namespace)))?;
194
195    let (removed, relation_display) = if let Some(rel) = args.relation.as_deref() {
196        // Single-relation mode: exact match required.
197        let row =
198            entities::find_relationship(&conn, source_id, target_id, rel)?.ok_or_else(|| {
199                AppError::NotFound(errors_msg::relationship_not_found(
200                    from_name, rel, to_name, &namespace,
201                ))
202            })?;
203
204        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
205        entities::delete_relationship_by_id(&tx, row.id)?;
206        entities::recalculate_degree(&tx, source_id)?;
207        entities::recalculate_degree(&tx, target_id)?;
208        tx.commit()?;
209
210        (1u64, rel.to_string())
211    } else {
212        // Bulk mode: delete all relationships between from and to.
213        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
214        let count = delete_relationships_between(&tx, source_id, target_id)?;
215        entities::recalculate_degree(&tx, source_id)?;
216        entities::recalculate_degree(&tx, target_id)?;
217        tx.commit()?;
218
219        (count, "*".to_string())
220    };
221
222    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
223
224    let response = UnlinkResponse {
225        action: "deleted".to_string(),
226        from_name: from_name.to_string(),
227        to_name: to_name.to_string(),
228        relation: relation_display.clone(),
229        relationships_removed: removed,
230        namespace: namespace.clone(),
231        elapsed_ms: inicio.elapsed().as_millis() as u64,
232    };
233
234    match args.format {
235        OutputFormat::Json => output::emit_json(&response)?,
236        OutputFormat::Text | OutputFormat::Markdown => {
237            output::emit_text(&format!(
238                "deleted: {} --[{}]--> {} removed {} relationship(s) [{}]",
239                response.from_name,
240                response.relation,
241                response.to_name,
242                response.relationships_removed,
243                response.namespace
244            ));
245        }
246    }
247
248    Ok(())
249}
250
251/// Deletes all relationships where `entity_id` is source or target.
252/// Returns the number of rows removed.
253fn delete_all_entity_relationships(
254    conn: &rusqlite::Connection,
255    entity_id: i64,
256) -> Result<u64, AppError> {
257    // Collect IDs first to clean up memory_relationships junction.
258    let mut stmt =
259        conn.prepare_cached("SELECT id FROM relationships WHERE source_id = ?1 OR target_id = ?1")?;
260    let ids: Vec<i64> = stmt
261        .query_map(rusqlite::params![entity_id], |r| r.get(0))?
262        .collect::<rusqlite::Result<Vec<_>>>()?;
263
264    let count = ids.len() as u64;
265    for rel_id in ids {
266        conn.execute(
267            "DELETE FROM memory_relationships WHERE relationship_id = ?1",
268            rusqlite::params![rel_id],
269        )?;
270        conn.execute(
271            "DELETE FROM relationships WHERE id = ?1",
272            rusqlite::params![rel_id],
273        )?;
274    }
275    Ok(count)
276}
277
278/// Deletes all relationships between `source_id` and `target_id` (any relation type).
279/// Returns the number of rows removed.
280fn delete_relationships_between(
281    conn: &rusqlite::Connection,
282    source_id: i64,
283    target_id: i64,
284) -> Result<u64, AppError> {
285    let mut stmt = conn
286        .prepare_cached("SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2")?;
287    let ids: Vec<i64> = stmt
288        .query_map(rusqlite::params![source_id, target_id], |r| r.get(0))?
289        .collect::<rusqlite::Result<Vec<_>>>()?;
290
291    let count = ids.len() as u64;
292    for rel_id in ids {
293        conn.execute(
294            "DELETE FROM memory_relationships WHERE relationship_id = ?1",
295            rusqlite::params![rel_id],
296        )?;
297        conn.execute(
298            "DELETE FROM relationships WHERE id = ?1",
299            rusqlite::params![rel_id],
300        )?;
301    }
302    Ok(count)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn unlink_response_serializes_all_fields() {
311        let resp = UnlinkResponse {
312            action: "deleted".to_string(),
313            from_name: "entity-a".to_string(),
314            to_name: "entity-b".to_string(),
315            relation: "uses".to_string(),
316            relationships_removed: 1,
317            namespace: "global".to_string(),
318            elapsed_ms: 5,
319        };
320        let json = serde_json::to_value(&resp).expect("serialization failed");
321        assert_eq!(json["action"], "deleted");
322        assert_eq!(json["from_name"], "entity-a");
323        assert_eq!(json["to_name"], "entity-b");
324        assert_eq!(json["relation"], "uses");
325        assert_eq!(json["relationships_removed"], 1u64);
326        assert_eq!(json["namespace"], "global");
327        assert_eq!(json["elapsed_ms"], 5u64);
328    }
329
330    #[test]
331    fn unlink_response_action_must_be_deleted() {
332        let resp = UnlinkResponse {
333            action: "deleted".to_string(),
334            from_name: "a".to_string(),
335            to_name: "b".to_string(),
336            relation: "related".to_string(),
337            relationships_removed: 1,
338            namespace: "global".to_string(),
339            elapsed_ms: 0,
340        };
341        let json = serde_json::to_value(&resp).expect("serialization failed");
342        assert_eq!(
343            json["action"], "deleted",
344            "unlink action must always be 'deleted'"
345        );
346    }
347
348    #[test]
349    fn unlink_response_bulk_uses_wildcard_relation() {
350        let resp = UnlinkResponse {
351            action: "deleted".to_string(),
352            from_name: "origin".to_string(),
353            to_name: "destination".to_string(),
354            relation: "*".to_string(),
355            relationships_removed: 3,
356            namespace: "project".to_string(),
357            elapsed_ms: 3,
358        };
359        let json = serde_json::to_value(&resp).expect("serialization failed");
360        assert_eq!(json["relation"], "*");
361        assert_eq!(json["relationships_removed"], 3u64);
362    }
363
364    #[test]
365    fn unlink_response_entity_all_uses_wildcard_to() {
366        let resp = UnlinkResponse {
367            action: "deleted".to_string(),
368            from_name: "oauth-flow".to_string(),
369            to_name: "*".to_string(),
370            relation: "*".to_string(),
371            relationships_removed: 5,
372            namespace: "global".to_string(),
373            elapsed_ms: 2,
374        };
375        let json = serde_json::to_value(&resp).expect("serialization failed");
376        assert_eq!(json["to_name"], "*");
377        assert_eq!(json["relation"], "*");
378        assert_eq!(json["relationships_removed"], 5u64);
379    }
380
381    // GAP-SG-52: `unlink --memory M --entity E` parses into the binding mode.
382    #[test]
383    fn unlink_memory_entity_binding_mode_parses() {
384        use crate::cli::{Cli, Commands};
385        use clap::Parser;
386        let cli = Cli::try_parse_from([
387            "sqlite-graphrag",
388            "unlink",
389            "--memory",
390            "my-mem",
391            "--entity",
392            "jwt-token",
393        ])
394        .expect("parse");
395        match cli.command {
396            Some(Commands::Unlink(a)) => {
397                assert_eq!(a.memory.as_deref(), Some("my-mem"));
398                assert_eq!(a.entity.as_deref(), Some("jwt-token"));
399                assert!(!a.all);
400            }
401            other => panic!("expected unlink, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn unlink_response_relationships_removed_field_present() {
407        let resp = UnlinkResponse {
408            action: "deleted".to_string(),
409            from_name: "a".to_string(),
410            to_name: "b".to_string(),
411            relation: "uses".to_string(),
412            relationships_removed: 0,
413            namespace: "global".to_string(),
414            elapsed_ms: 0,
415        };
416        let json = serde_json::to_value(&resp).expect("serialization failed");
417        assert!(
418            json.get("relationships_removed").is_some(),
419            "relationships_removed field must be present"
420        );
421    }
422}