1use crate::errors::AppError;
11use crate::i18n::errors_msg;
12use crate::output::{self, OutputFormat};
13use crate::paths::AppPaths;
14use crate::storage::connection::open_rw;
15use crate::storage::entities;
16use rusqlite::params;
17use serde::Serialize;
18
19#[derive(clap::Args)]
20#[command(after_long_help = "EXAMPLES:\n \
21 # Merge two source entities into a target\n \
22 sqlite-graphrag merge-entities --names auth,authentication --into auth-service\n\n \
23 # Merge three sources into one target across a namespace\n \
24 sqlite-graphrag merge-entities --names svc-a,svc-b,old-svc --into canonical-service --namespace my-project\n\n \
25 # Merge by ID (unambiguous when homonyms exist across namespaces)\n \
26 sqlite-graphrag merge-entities --ids 12,17 --into-id 3\n\n\
27NOTE:\n \
28 --names is a comma-separated list of source entity names.\n \
29 --into is the target entity name and must already exist.\n \
30 --ids / --into-id select entities by ID; IDs are globally unique so they\n \
31 disambiguate homonyms. They conflict with --names / --into respectively\n \
32 and must belong to the resolved namespace.\n \
33 Source entities are deleted after the merge; the target is preserved.\n \
34 Duplicate relationships (same endpoints + relation) are removed automatically.\n \
35 Run `sqlite-graphrag cleanup-orphans` afterwards if sources had no other links.")]
36pub struct MergeEntitiesArgs {
38 #[arg(
40 long,
41 value_delimiter = ',',
42 value_name = "NAMES",
43 required_unless_present = "ids",
44 conflicts_with = "ids"
45 )]
46 pub names: Vec<String>,
47 #[arg(long, value_delimiter = ',', value_name = "IDS")]
51 pub ids: Vec<i64>,
52 #[arg(
54 long,
55 value_name = "TARGET",
56 required_unless_present = "into_id",
57 conflicts_with = "into_id"
58 )]
59 pub into: Option<String>,
60 #[arg(long, value_name = "TARGET_ID")]
62 pub into_id: Option<i64>,
63 #[arg(long)]
65 pub namespace: Option<String>,
66 #[arg(long, value_enum, default_value = "json")]
68 pub format: OutputFormat,
69 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
71 pub json: bool,
72 #[arg(long)]
74 pub db: Option<String>,
75 #[arg(long, default_value_t = false, hide = false)]
80 pub cross_namespace: bool,
81}
82
83#[derive(Serialize)]
84struct MergeEntitiesResponse {
85 action: String,
86 sources: Vec<String>,
87 target: String,
88 namespace: String,
89 target_id: i64,
91 relationships_moved: usize,
92 entities_removed: usize,
93 elapsed_ms: u64,
95}
96
97fn find_entity_name_by_id(
104 conn: &rusqlite::Connection,
105 namespace: &str,
106 id: i64,
107 enforce_namespace: bool,
108) -> Result<(String, String), AppError> {
109 let mut stmt = if enforce_namespace {
110 conn.prepare_cached(
111 "SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2",
112 )?
113 } else {
114 conn.prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1")?
115 };
116 let row = if enforce_namespace {
117 stmt.query_row(params![id, namespace], |r| {
118 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
119 })
120 } else {
121 stmt.query_row(params![id], |r| {
122 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
123 })
124 };
125 match row {
126 Ok((name, ns_actual)) => Ok((name, ns_actual)),
127 Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
128 "entity id={id} not found in namespace '{namespace}'"
129 ))),
130 Err(e) => Err(AppError::Database(e)),
131 }
132}
133
134pub fn run(args: MergeEntitiesArgs) -> Result<(), AppError> {
136 let inicio = std::time::Instant::now();
137
138 if args.names.is_empty() && args.ids.is_empty() {
139 return Err(AppError::Validation(
140 "--names or --ids must contain at least one source entity".to_string(),
141 ));
142 }
143
144 if let Some(target_id) = args.into_id {
147 if args.ids.contains(&target_id) {
148 return Err(AppError::Validation(crate::i18n::validation::self_merge_id_in_ids(target_id)));
149 }
150 }
151 if let Some(ref target_name) = args.into {
152 if args.names.iter().any(|n| n == target_name) {
153 return Err(AppError::Validation(crate::i18n::validation::self_merge_name_in_names(target_name)));
154 }
155 }
156
157 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
158 let paths = AppPaths::resolve(args.db.as_deref())?;
159
160 crate::storage::connection::ensure_db_ready(&paths)?;
161
162 let mut conn = open_rw(&paths.db)?;
163
164 let (target_id, target_name) = match args.into_id {
167 Some(id) => {
168 let (name, _ns_actual) = find_entity_name_by_id(&conn, &namespace, id, true)?;
171 (id, name)
172 }
173 None => {
174 let Some(name) = args.into.clone() else {
175 return Err(AppError::Validation(
176 "--into or --into-id is required".to_string(),
177 ));
178 };
179 let id = entities::find_entity_id(&conn, &namespace, &name)?.ok_or_else(|| {
180 AppError::NotFound(errors_msg::entity_not_found(&name, &namespace))
181 })?;
182 (id, name)
183 }
184 };
185
186 let mut source_ids: Vec<i64> = Vec::with_capacity(args.names.len() + args.ids.len());
190 let mut source_names: Vec<String> = Vec::with_capacity(source_ids.capacity());
191 if !args.ids.is_empty() {
192 for &id in &args.ids {
193 if id == target_id {
194 return Err(AppError::Validation(crate::i18n::validation::self_merge_id(id, target_id)));
195 }
196 let (name, ns_actual) =
200 find_entity_name_by_id(&conn, &namespace, id, !args.cross_namespace)?;
201 if args.cross_namespace && ns_actual != namespace {
202 tracing::warn!(
203 target: "merge_entities",
204 from_id = id,
205 from_namespace = %ns_actual,
206 to_namespace = %namespace,
207 "cross-namespace merge"
208 );
209 }
210 if !source_ids.contains(&id) {
211 source_ids.push(id);
212 source_names.push(name);
213 }
214 }
215 } else {
216 for name in &args.names {
217 if name == &target_name {
218 return Err(AppError::Validation(crate::i18n::validation::self_merge_name(name, &target_name)));
219 }
220 let id = entities::find_entity_id(&conn, &namespace, name)?.ok_or_else(|| {
221 AppError::NotFound(errors_msg::entity_not_found(name, &namespace))
222 })?;
223 if id == target_id {
224 return Err(AppError::Validation(crate::i18n::validation::self_merge_name_resolves_to_target(name, target_id)));
225 }
226 if !source_ids.contains(&id) {
227 source_ids.push(id);
228 source_names.push(name.clone());
229 }
230 }
231 }
232
233 if source_ids.is_empty() {
234 return Err(AppError::Validation(
235 "no valid source entities to merge (all names equal the target or were duplicates)"
236 .to_string(),
237 ));
238 }
239
240 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
241
242 let mut relationships_moved: usize = 0;
243
244 for &src_id in &source_ids {
245 let moved_src = tx.execute(
247 "UPDATE OR IGNORE relationships SET source_id = ?1 WHERE source_id = ?2",
248 params![target_id, src_id],
249 )?;
250 tx.execute(
251 "DELETE FROM relationships WHERE source_id = ?1",
252 params![src_id],
253 )?;
254 let moved_tgt = tx.execute(
256 "UPDATE OR IGNORE relationships SET target_id = ?1 WHERE target_id = ?2",
257 params![target_id, src_id],
258 )?;
259 tx.execute(
260 "DELETE FROM relationships WHERE target_id = ?1",
261 params![src_id],
262 )?;
263 relationships_moved += moved_src + moved_tgt;
264 }
265
266 tx.execute("DELETE FROM relationships WHERE source_id = target_id", [])?;
268
269 tx.execute(
272 "DELETE FROM relationships
273 WHERE id NOT IN (
274 SELECT MIN(id)
275 FROM relationships
276 GROUP BY source_id, target_id, relation
277 )",
278 [],
279 )?;
280
281 for &src_id in &source_ids {
286 tx.execute(
287 "UPDATE OR IGNORE memory_entities SET entity_id = ?1 WHERE entity_id = ?2",
288 params![target_id, src_id],
289 )?;
290 tx.execute(
291 "DELETE FROM memory_entities WHERE entity_id = ?1",
292 params![src_id],
293 )?;
294 }
295
296 tx.execute(
298 "DELETE FROM memory_entities
299 WHERE rowid NOT IN (
300 SELECT MIN(rowid)
301 FROM memory_entities
302 GROUP BY memory_id, entity_id
303 )",
304 [],
305 )?;
306
307 let mut entities_removed: usize = 0;
310 for &src_id in &source_ids {
311 let removed = tx.execute("DELETE FROM entities WHERE id = ?1", params![src_id])?;
312 entities_removed += removed;
313 }
314
315 let adjacent_ids: Vec<i64> = {
317 let mut stmt = tx.prepare(
318 "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
319 FROM relationships WHERE source_id = ?1 OR target_id = ?1",
320 )?;
321 let ids: Vec<i64> = stmt
322 .query_map(params![target_id], |r| r.get(0))?
323 .collect::<Result<Vec<_>, _>>()?;
324 ids
325 };
326 entities::recalculate_degree(&tx, target_id)?;
327 for &adj_id in &adjacent_ids {
328 entities::recalculate_degree(&tx, adj_id)?;
329 }
330
331 tx.commit()?;
332
333 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
334
335 let response = MergeEntitiesResponse {
336 action: "merged".to_string(),
337 sources: source_names,
338 target: target_name,
339 namespace: namespace.clone(),
340 target_id,
341 relationships_moved,
342 entities_removed,
343 elapsed_ms: inicio.elapsed().as_millis() as u64,
344 };
345
346 match args.format {
347 OutputFormat::Json => output::emit_json(&response)?,
348 OutputFormat::Text | OutputFormat::Markdown => {
349 output::emit_text(&format!(
350 "merged: {} sources into '{}' (relationships_moved={}, entities_removed={}) [{}]",
351 response.sources.len(),
352 response.target,
353 response.relationships_moved,
354 response.entities_removed,
355 response.namespace
356 ));
357 }
358 }
359
360 Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
370 fn find_entity_name_by_id_disambiguates_homonyms_across_namespaces() {
371 let conn = rusqlite::Connection::open_in_memory().unwrap();
372 conn.execute_batch(
373 "CREATE TABLE entities (
374 id INTEGER PRIMARY KEY,
375 namespace TEXT NOT NULL,
376 name TEXT NOT NULL,
377 UNIQUE(namespace, name)
378 );",
379 )
380 .unwrap();
381 conn.execute(
382 "INSERT INTO entities (id, namespace, name)
383 VALUES (1, 'ns-a', 'auth'), (2, 'ns-b', 'auth')",
384 [],
385 )
386 .unwrap();
387
388 assert_eq!(
390 find_entity_name_by_id(&conn, "ns-a", 1, true).unwrap(),
391 ("auth".to_string(), "ns-a".to_string())
392 );
393 assert_eq!(
394 find_entity_name_by_id(&conn, "ns-b", 2, true).unwrap(),
395 ("auth".to_string(), "ns-b".to_string())
396 );
397 let err = find_entity_name_by_id(&conn, "ns-a", 2, true).unwrap_err();
398 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
399 assert!(err.to_string().contains("id=2"), "obtido: {err}");
400
401 let (name, ns_actual) = find_entity_name_by_id(&conn, "ns-a", 2, false).unwrap();
404 assert_eq!(name, "auth");
405 assert_eq!(ns_actual, "ns-b");
406 }
407
408 #[test]
409 fn find_entity_name_by_id_missing_id_is_not_found() {
410 let conn = rusqlite::Connection::open_in_memory().unwrap();
411 conn.execute_batch(
412 "CREATE TABLE entities (
413 id INTEGER PRIMARY KEY,
414 namespace TEXT NOT NULL,
415 name TEXT NOT NULL
416 );",
417 )
418 .unwrap();
419 let err = find_entity_name_by_id(&conn, "global", 99, true).unwrap_err();
420 assert_eq!(err.exit_code(), 4);
421 }
422
423 #[derive(clap::Parser)]
426 struct TestCli {
427 #[command(flatten)]
428 args: MergeEntitiesArgs,
429 }
430
431 #[test]
432 fn clap_rejects_names_combined_with_ids() {
433 use clap::Parser;
434 let err =
435 match TestCli::try_parse_from(["t", "--names", "a,b", "--ids", "1,2", "--into", "tgt"])
436 {
437 Ok(_) => panic!("expected argument conflict"),
438 Err(e) => e,
439 };
440 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
441 }
442
443 #[test]
444 fn clap_rejects_into_combined_with_into_id() {
445 use clap::Parser;
446 let err =
447 match TestCli::try_parse_from(["t", "--names", "a", "--into", "tgt", "--into-id", "3"])
448 {
449 Ok(_) => panic!("expected argument conflict"),
450 Err(e) => e,
451 };
452 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
453 }
454
455 #[test]
459 fn self_referential_ids_rejected_before_db() {
460 let args = MergeEntitiesArgs {
461 names: vec![],
462 ids: vec![35575, 35340],
463 into: None,
464 into_id: Some(35340),
465 namespace: Some("global".into()),
466 format: OutputFormat::Json,
467 json: false,
468 db: Some("/nonexistent/no-db.sqlite".into()),
469 cross_namespace: true,
470 };
471 let err = run(args).expect_err("self-ref must fail");
472 assert_eq!(err.exit_code(), 1, "validation exit");
473 let msg = err.to_string();
474 assert!(
475 msg.contains("self-referential") || msg.contains("35340"),
476 "expected self-ref message, got: {msg}"
477 );
478 }
479
480 #[test]
481 fn clap_parses_ids_with_target_in_list() {
482 use clap::Parser;
483 let ok = TestCli::try_parse_from([
484 "t",
485 "--ids",
486 "35575,35340",
487 "--into-id",
488 "35340",
489 "--cross-namespace",
490 ])
491 .expect("must parse");
492 assert_eq!(ok.args.ids, vec![35575, 35340]);
493 assert_eq!(ok.args.into_id, Some(35340));
494 assert!(ok.args.cross_namespace);
495 }
496
497 #[test]
498 fn clap_requires_a_source_and_a_target_selector() {
499 use clap::Parser;
500 assert!(TestCli::try_parse_from(["t", "--into", "tgt"]).is_err());
501 assert!(TestCli::try_parse_from(["t", "--names", "a"]).is_err());
502 let ok = match TestCli::try_parse_from(["t", "--ids", "1,2", "--into-id", "3"]) {
503 Ok(cli) => cli,
504 Err(e) => panic!("expected successful parse: {e}"),
505 };
506 assert_eq!(ok.args.ids, vec![1, 2]);
507 assert_eq!(ok.args.into_id, Some(3));
508 assert!(ok.args.names.is_empty());
509 assert!(ok.args.into.is_none());
510 }
511
512 #[test]
513 fn merge_entities_response_serializes_all_fields() {
514 let resp = MergeEntitiesResponse {
515 action: "merged".to_string(),
516 sources: vec!["auth".to_string(), "authentication".to_string()],
517 target: "auth-service".to_string(),
518 namespace: "global".to_string(),
519 target_id: 1,
520 relationships_moved: 7,
521 entities_removed: 2,
522 elapsed_ms: 15,
523 };
524 let json = serde_json::to_value(&resp).expect("serialization failed");
525 assert_eq!(json["action"], "merged");
526 assert_eq!(json["target"], "auth-service");
527 assert_eq!(json["namespace"], "global");
528 assert_eq!(json["relationships_moved"], 7);
529 assert_eq!(json["entities_removed"], 2);
530 let sources = json["sources"].as_array().expect("must be array");
531 assert_eq!(sources.len(), 2);
532 assert!(json["elapsed_ms"].is_number());
533 }
534
535 #[test]
536 fn merge_entities_response_action_is_merged() {
537 let resp = MergeEntitiesResponse {
538 action: "merged".to_string(),
539 sources: vec!["src".to_string()],
540 target: "tgt".to_string(),
541 namespace: "ns".to_string(),
542 target_id: 1,
543 relationships_moved: 0,
544 entities_removed: 1,
545 elapsed_ms: 0,
546 };
547 assert_eq!(resp.action, "merged");
548 }
549
550 #[test]
551 fn merge_entities_response_empty_sources_serializes() {
552 let resp = MergeEntitiesResponse {
553 action: "merged".to_string(),
554 sources: vec![],
555 target: "target".to_string(),
556 namespace: "global".to_string(),
557 target_id: 1,
558 relationships_moved: 0,
559 entities_removed: 0,
560 elapsed_ms: 1,
561 };
562 let json = serde_json::to_value(&resp).expect("serialization failed");
563 let sources = json["sources"].as_array().expect("must be array");
564 assert_eq!(sources.len(), 0);
565 }
566
567 #[test]
568 fn merge_entities_response_with_zero_relationships_moved() {
569 let resp = MergeEntitiesResponse {
570 action: "merged".to_string(),
571 sources: vec!["src-a".to_string()],
572 target: "tgt".to_string(),
573 namespace: "global".to_string(),
574 target_id: 1,
575 relationships_moved: 0,
576 entities_removed: 1,
577 elapsed_ms: 5,
578 };
579 let json = serde_json::to_value(&resp).expect("serialization failed");
580 assert_eq!(json["relationships_moved"], 0);
581 assert_eq!(json["entities_removed"], 1);
582 }
583
584 #[test]
585 fn merge_entities_response_multiple_sources() {
586 let resp = MergeEntitiesResponse {
587 action: "merged".to_string(),
588 sources: vec!["a".into(), "b".into(), "c".into()],
589 target: "canonical".to_string(),
590 namespace: "proj".to_string(),
591 target_id: 1,
592 relationships_moved: 12,
593 entities_removed: 3,
594 elapsed_ms: 42,
595 };
596 let json = serde_json::to_value(&resp).expect("serialization failed");
597 assert_eq!(json["entities_removed"], 3);
598 let sources = json["sources"].as_array().unwrap();
599 assert_eq!(sources.len(), 3);
600 }
601
602 fn setup_migrated_db_on_disk() -> (tempfile::TempDir, rusqlite::Connection, std::path::PathBuf)
606 {
607 crate::storage::connection::register_vec_extension();
608 let tmp = tempfile::TempDir::new().expect("tempdir");
609 let db_path = tmp.path().join("test.db");
610 let mut conn = rusqlite::Connection::open(&db_path).expect("open");
611 crate::migrations::runner().run(&mut conn).expect("migrate");
612 (tmp, conn, db_path)
613 }
614
615 #[test]
619 fn cross_namespace_merges_source_from_other_namespace() {
620 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
621 conn.execute(
623 "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
624 [],
625 )
626 .unwrap();
627 let tgt_id = conn.last_insert_rowid();
628 conn.execute(
630 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
631 [],
632 )
633 .unwrap();
634 let src_id = conn.last_insert_rowid();
635 conn.execute(
638 "INSERT INTO entities (namespace, name, type) VALUES ('global','third','concept')",
639 [],
640 )
641 .unwrap();
642 let third_id = conn.last_insert_rowid();
643 conn.execute(
644 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
645 VALUES ('ai-sdd', ?1, ?2, 'related', 0.5)",
646 params![src_id, third_id],
647 )
648 .unwrap();
649 drop(conn);
651
652 let args = MergeEntitiesArgs {
653 names: vec![],
654 ids: vec![src_id],
655 into: None,
656 into_id: Some(tgt_id),
657 namespace: Some("global".to_string()),
658 format: OutputFormat::Json,
659 json: false,
660 db: Some(db_path.to_string_lossy().into_owned()),
661 cross_namespace: true,
662 };
663 run(args).expect("cross-namespace merge must succeed");
664
665 let conn = rusqlite::Connection::open(&db_path).unwrap();
667 let src_remaining: i64 = conn
668 .query_row(
669 "SELECT COUNT(*) FROM entities WHERE id = ?1",
670 params![src_id],
671 |r| r.get(0),
672 )
673 .unwrap();
674 assert_eq!(src_remaining, 0, "cross-namespace source must be deleted");
675 let moved: i64 = conn
676 .query_row(
677 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 AND target_id = ?2",
678 params![tgt_id, third_id],
679 |r| r.get(0),
680 )
681 .unwrap();
682 assert!(
683 moved > 0,
684 "relationship must be retargeted to the target entity"
685 );
686 }
687
688 #[test]
692 fn cross_namespace_default_false_rejects_cross_id() {
693 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
694 conn.execute(
695 "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
696 [],
697 )
698 .unwrap();
699 let tgt_id = conn.last_insert_rowid();
700 conn.execute(
701 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
702 [],
703 )
704 .unwrap();
705 let src_id = conn.last_insert_rowid();
706 drop(conn);
707
708 let args = MergeEntitiesArgs {
709 names: vec![],
710 ids: vec![src_id],
711 into: None,
712 into_id: Some(tgt_id),
713 namespace: Some("global".to_string()),
714 format: OutputFormat::Json,
715 json: false,
716 db: Some(db_path.to_string_lossy().into_owned()),
717 cross_namespace: false,
718 };
719 let err = run(args).expect_err("default must reject cross-namespace ID");
720 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
721 }
722
723 #[test]
726 fn cross_namespace_target_must_still_be_in_resolved_namespace() {
727 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
728 conn.execute(
730 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','tgt','concept')",
731 [],
732 )
733 .unwrap();
734 let tgt_id = conn.last_insert_rowid();
735 conn.execute(
736 "INSERT INTO entities (namespace, name, type) VALUES ('global','src','concept')",
737 [],
738 )
739 .unwrap();
740 let src_id = conn.last_insert_rowid();
741 drop(conn);
742
743 let args = MergeEntitiesArgs {
744 names: vec![],
745 ids: vec![src_id],
746 into: None,
747 into_id: Some(tgt_id),
748 namespace: Some("global".to_string()),
749 format: OutputFormat::Json,
750 json: false,
751 db: Some(db_path.to_string_lossy().into_owned()),
752 cross_namespace: true,
753 };
754 let err = run(args).expect_err("target in wrong namespace must fail");
755 assert_eq!(
756 err.exit_code(),
757 4,
758 "target must still be NotFound in the resolved namespace"
759 );
760 }
761}