Skip to main content

sqlite_graphrag/commands/merge_entities/
mod.rs

1//! Handler for the `merge-entities` CLI subcommand (GAP-19).
2//!
3//! Merges two or more source entities into a single target entity by:
4//!   1. Retargeting all relationships pointing at any source to the target.
5//!   2. Deduplicating relationships that become identical after the merge
6//!      (same source_id + target_id + relation).
7//!   3. Retargeting memory_entities bindings.
8//!   4. Deleting the now-empty source entity rows.
9//!
10//! `args` holds the CLI surface, `envelope` the JSON report and `resolve`
11//! the namespace-scoped ID lookup; the merge transaction itself stays here.
12
13use crate::errors::AppError;
14use crate::i18n::errors_msg;
15use crate::output::{self, OutputFormat};
16use crate::paths::AppPaths;
17use crate::storage::connection::open_rw;
18use crate::storage::entities;
19use rusqlite::params;
20
21mod args;
22mod envelope;
23mod resolve;
24
25pub use args::MergeEntitiesArgs;
26use envelope::MergeEntitiesResponse;
27use resolve::find_entity_name_by_id;
28
29/// Run.
30pub fn run(args: MergeEntitiesArgs) -> Result<(), AppError> {
31    let started = std::time::Instant::now();
32
33    if args.names.is_empty() && args.ids.is_empty() {
34        return Err(AppError::Validation(
35            "--names or --ids must contain at least one source entity".to_string(),
36        ));
37    }
38
39    // v1.1.05 Bug 4: reject self-referential merge at the earliest possible
40    // point (before any DB work), so shell word-splitting mistakes fail loud.
41    if let Some(target_id) = args.into_id {
42        if args.ids.contains(&target_id) {
43            return Err(AppError::Validation(
44                crate::i18n::validation::self_merge_id_in_ids(target_id),
45            ));
46        }
47    }
48    if let Some(ref target_name) = args.into {
49        if args.names.iter().any(|n| n == target_name) {
50            return Err(AppError::Validation(
51                crate::i18n::validation::self_merge_name_in_names(target_name),
52            ));
53        }
54    }
55
56    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
57    let paths = AppPaths::resolve(args.db.as_deref())?;
58
59    crate::storage::connection::ensure_db_ready(&paths)?;
60
61    let mut conn = open_rw(&paths.db)?;
62
63    // Resolve target entity — by ID (v1.1.1 P5, unambiguous) or by name.
64    // Existence is validated here, BEFORE any mutation.
65    let (target_id, target_name) = match args.into_id {
66        Some(id) => {
67            // Target is always validated in the resolved namespace, even when
68            // --cross-namespace is set: cross-namespace only relaxes SOURCES.
69            let (name, _ns_actual) = find_entity_name_by_id(&conn, &namespace, id, true)?;
70            (id, name)
71        }
72        None => {
73            let Some(name) = args.into.clone() else {
74                return Err(AppError::Validation(
75                    "--into or --into-id is required".to_string(),
76                ));
77            };
78            let id = entities::find_entity_id(&conn, &namespace, &name)?.ok_or_else(|| {
79                AppError::NotFound(errors_msg::entity_not_found(&name, &namespace))
80            })?;
81            (id, name)
82        }
83    };
84
85    // Resolve source entity IDs — reject self-referential merge (G21),
86    // by ID (v1.1.1 P5) or by name. All lookups happen BEFORE the transaction.
87    // Defense-in-depth: re-check even after early parse-time guard (Bug 4).
88    let mut source_ids: Vec<i64> = Vec::with_capacity(args.names.len() + args.ids.len());
89    let mut source_names: Vec<String> = Vec::with_capacity(source_ids.capacity());
90    if !args.ids.is_empty() {
91        for &id in &args.ids {
92            if id == target_id {
93                return Err(AppError::Validation(
94                    crate::i18n::validation::self_merge_id(id, target_id),
95                ));
96            }
97            // v1.1.03: when --cross-namespace is set, resolve each source by its
98            // own row (no namespace filter) and warn on the cross-namespace move.
99            // Default (false) preserves same-namespace safety.
100            let (name, ns_actual) =
101                find_entity_name_by_id(&conn, &namespace, id, !args.cross_namespace)?;
102            if args.cross_namespace && ns_actual != namespace {
103                tracing::warn!(
104                    target: "merge_entities",
105                    from_id = id,
106                    from_namespace = %ns_actual,
107                    to_namespace = %namespace,
108                    "cross-namespace merge"
109                );
110            }
111            if !source_ids.contains(&id) {
112                source_ids.push(id);
113                source_names.push(name);
114            }
115        }
116    } else {
117        for name in &args.names {
118            if name == &target_name {
119                return Err(AppError::Validation(
120                    crate::i18n::validation::self_merge_name(name, &target_name),
121                ));
122            }
123            let id = entities::find_entity_id(&conn, &namespace, name)?.ok_or_else(|| {
124                AppError::NotFound(errors_msg::entity_not_found(name, &namespace))
125            })?;
126            if id == target_id {
127                return Err(AppError::Validation(
128                    crate::i18n::validation::self_merge_name_resolves_to_target(name, target_id),
129                ));
130            }
131            if !source_ids.contains(&id) {
132                source_ids.push(id);
133                source_names.push(name.clone());
134            }
135        }
136    }
137
138    if source_ids.is_empty() {
139        return Err(AppError::Validation(
140            "no valid source entities to merge (all names equal the target or were duplicates)"
141                .to_string(),
142        ));
143    }
144
145    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
146
147    let mut relationships_moved: usize = 0;
148
149    for &src_id in &source_ids {
150        // Step 1a: redirect source_id, ignoring UNIQUE conflicts.
151        let moved_src = tx.execute(
152            "UPDATE OR IGNORE relationships SET source_id = ?1 WHERE source_id = ?2",
153            params![target_id, src_id],
154        )?;
155        tx.execute(
156            "DELETE FROM relationships WHERE source_id = ?1",
157            params![src_id],
158        )?;
159        // Step 1b: redirect target_id, ignoring UNIQUE conflicts.
160        let moved_tgt = tx.execute(
161            "UPDATE OR IGNORE relationships SET target_id = ?1 WHERE target_id = ?2",
162            params![target_id, src_id],
163        )?;
164        tx.execute(
165            "DELETE FROM relationships WHERE target_id = ?1",
166            params![src_id],
167        )?;
168        relationships_moved += moved_src + moved_tgt;
169    }
170
171    // Step 2: remove self-loops introduced by the redirect (target → target).
172    tx.execute("DELETE FROM relationships WHERE source_id = target_id", [])?;
173
174    // Step 3: deduplicate relationships that now share (source, target, relation).
175    // Safety net — UPDATE OR IGNORE should have handled most duplicates above.
176    tx.execute(
177        "DELETE FROM relationships
178         WHERE id NOT IN (
179             SELECT MIN(id)
180             FROM relationships
181             GROUP BY source_id, target_id, relation
182         )",
183        [],
184    )?;
185
186    // Step 4: retarget memory_entities bindings.
187    // Use UPDATE OR IGNORE to skip conflicts when memory is already bound to
188    // target entity. Then DELETE remaining source rows (the conflicting ones
189    // that UPDATE OR IGNORE skipped). Same pattern as relationships (Step 1).
190    for &src_id in &source_ids {
191        tx.execute(
192            "UPDATE OR IGNORE memory_entities SET entity_id = ?1 WHERE entity_id = ?2",
193            params![target_id, src_id],
194        )?;
195        tx.execute(
196            "DELETE FROM memory_entities WHERE entity_id = ?1",
197            params![src_id],
198        )?;
199    }
200
201    // Step 5: deduplicate memory_entities bindings (same memory + entity).
202    tx.execute(
203        "DELETE FROM memory_entities
204         WHERE rowid NOT IN (
205             SELECT MIN(rowid)
206             FROM memory_entities
207             GROUP BY memory_id, entity_id
208         )",
209        [],
210    )?;
211
212    // Step 6: delete source entities. v1.0.76: FK ON DELETE CASCADE on
213    // entity_embeddings handles the vector row automatically.
214    let mut entities_removed: usize = 0;
215    for &src_id in &source_ids {
216        let removed = tx.execute("DELETE FROM entities WHERE id = ?1", params![src_id])?;
217        entities_removed += removed;
218    }
219
220    // Step 7: recalculate degree for target and all adjacent entities.
221    let adjacent_ids: Vec<i64> = {
222        let mut stmt = tx.prepare(
223            "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
224             FROM relationships WHERE source_id = ?1 OR target_id = ?1",
225        )?;
226        let ids: Vec<i64> = stmt
227            .query_map(params![target_id], |r| r.get(0))?
228            .collect::<Result<Vec<_>, _>>()?;
229        ids
230    };
231    entities::recalculate_degree(&tx, target_id)?;
232    for &adj_id in &adjacent_ids {
233        entities::recalculate_degree(&tx, adj_id)?;
234    }
235
236    tx.commit()?;
237
238    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
239
240    let response = MergeEntitiesResponse {
241        action: "merged".to_string(),
242        sources: source_names,
243        target: target_name,
244        namespace: namespace.clone(),
245        target_id,
246        relationships_moved,
247        entities_removed,
248        elapsed_ms: started.elapsed().as_millis() as u64,
249    };
250
251    match args.format {
252        OutputFormat::Json => output::emit_json(&response)?,
253        OutputFormat::Text | OutputFormat::Markdown => {
254            output::emit_text(&format!(
255                "merged: {} sources into '{}' (relationships_moved={}, entities_removed={}) [{}]",
256                response.sources.len(),
257                response.target,
258                response.relationships_moved,
259                response.entities_removed,
260                response.namespace
261            ));
262        }
263    }
264
265    Ok(())
266}
267
268#[cfg(test)]
269mod tests;