sqlite_graphrag/commands/merge_entities/
mod.rs1use 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
29pub 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 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 let (target_id, target_name) = match args.into_id {
66 Some(id) => {
67 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 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 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 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 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 tx.execute("DELETE FROM relationships WHERE source_id = target_id", [])?;
173
174 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 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 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 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 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;