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