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 {
37 #[arg(
39 long,
40 value_delimiter = ',',
41 value_name = "NAMES",
42 required_unless_present = "ids",
43 conflicts_with = "ids"
44 )]
45 pub names: Vec<String>,
46 #[arg(long, value_delimiter = ',', value_name = "IDS")]
50 pub ids: Vec<i64>,
51 #[arg(
53 long,
54 value_name = "TARGET",
55 required_unless_present = "into_id",
56 conflicts_with = "into_id"
57 )]
58 pub into: Option<String>,
59 #[arg(long, value_name = "TARGET_ID")]
61 pub into_id: Option<i64>,
62 #[arg(long)]
63 pub namespace: Option<String>,
64 #[arg(long, value_enum, default_value = "json")]
65 pub format: OutputFormat,
66 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
67 pub json: bool,
68 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
69 pub db: Option<String>,
70 #[arg(long, default_value_t = false, hide = false)]
75 pub cross_namespace: bool,
76}
77
78#[derive(Serialize)]
79struct MergeEntitiesResponse {
80 action: String,
81 sources: Vec<String>,
82 target: String,
83 namespace: String,
84 target_id: i64,
86 relationships_moved: usize,
87 entities_removed: usize,
88 elapsed_ms: u64,
90}
91
92fn find_entity_name_by_id(
99 conn: &rusqlite::Connection,
100 namespace: &str,
101 id: i64,
102 enforce_namespace: bool,
103) -> Result<(String, String), AppError> {
104 let mut stmt = if enforce_namespace {
105 conn.prepare_cached(
106 "SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2",
107 )?
108 } else {
109 conn.prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1")?
110 };
111 let row = if enforce_namespace {
112 stmt.query_row(params![id, namespace], |r| {
113 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
114 })
115 } else {
116 stmt.query_row(params![id], |r| {
117 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
118 })
119 };
120 match row {
121 Ok((name, ns_actual)) => Ok((name, ns_actual)),
122 Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
123 "entity id={id} not found in namespace '{namespace}'"
124 ))),
125 Err(e) => Err(AppError::Database(e)),
126 }
127}
128
129pub fn run(args: MergeEntitiesArgs) -> Result<(), AppError> {
130 let inicio = std::time::Instant::now();
131
132 if args.names.is_empty() && args.ids.is_empty() {
133 return Err(AppError::Validation(
134 "--names or --ids must contain at least one source entity".to_string(),
135 ));
136 }
137
138 if let Some(target_id) = args.into_id {
141 if args.ids.contains(&target_id) {
142 return Err(AppError::Validation(format!(
143 "source entity id={target_id} equals target id={target_id} — \
144 self-referential merge is not allowed (remove target from --ids)"
145 )));
146 }
147 }
148 if let Some(ref target_name) = args.into {
149 if args.names.iter().any(|n| n == target_name) {
150 return Err(AppError::Validation(format!(
151 "source entity '{target_name}' equals target '{target_name}' — \
152 self-referential merge is not allowed (remove target from --names)"
153 )));
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(format!(
195 "source entity id={id} equals target id={target_id} — \
196 self-referential merge is not allowed"
197 )));
198 }
199 let (name, ns_actual) =
203 find_entity_name_by_id(&conn, &namespace, id, !args.cross_namespace)?;
204 if args.cross_namespace && ns_actual != namespace {
205 tracing::warn!(
206 target: "merge_entities",
207 from_id = id,
208 from_namespace = %ns_actual,
209 to_namespace = %namespace,
210 "cross-namespace merge"
211 );
212 }
213 if !source_ids.contains(&id) {
214 source_ids.push(id);
215 source_names.push(name);
216 }
217 }
218 } else {
219 for name in &args.names {
220 if name == &target_name {
221 return Err(AppError::Validation(format!(
222 "source entity '{name}' equals target '{target_name}' — \
223 self-referential merge is not allowed"
224 )));
225 }
226 let id = entities::find_entity_id(&conn, &namespace, name)?.ok_or_else(|| {
227 AppError::NotFound(errors_msg::entity_not_found(name, &namespace))
228 })?;
229 if id == target_id {
230 return Err(AppError::Validation(format!(
231 "source entity '{name}' resolves to the target (id={target_id}) — \
232 self-referential merge is not allowed"
233 )));
234 }
235 if !source_ids.contains(&id) {
236 source_ids.push(id);
237 source_names.push(name.clone());
238 }
239 }
240 }
241
242 if source_ids.is_empty() {
243 return Err(AppError::Validation(
244 "no valid source entities to merge (all names equal the target or were duplicates)"
245 .to_string(),
246 ));
247 }
248
249 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
250
251 let mut relationships_moved: usize = 0;
252
253 for &src_id in &source_ids {
254 let moved_src = tx.execute(
256 "UPDATE OR IGNORE relationships SET source_id = ?1 WHERE source_id = ?2",
257 params![target_id, src_id],
258 )?;
259 tx.execute(
260 "DELETE FROM relationships WHERE source_id = ?1",
261 params![src_id],
262 )?;
263 let moved_tgt = tx.execute(
265 "UPDATE OR IGNORE relationships SET target_id = ?1 WHERE target_id = ?2",
266 params![target_id, src_id],
267 )?;
268 tx.execute(
269 "DELETE FROM relationships WHERE target_id = ?1",
270 params![src_id],
271 )?;
272 relationships_moved += moved_src + moved_tgt;
273 }
274
275 tx.execute("DELETE FROM relationships WHERE source_id = target_id", [])?;
277
278 tx.execute(
281 "DELETE FROM relationships
282 WHERE id NOT IN (
283 SELECT MIN(id)
284 FROM relationships
285 GROUP BY source_id, target_id, relation
286 )",
287 [],
288 )?;
289
290 for &src_id in &source_ids {
295 tx.execute(
296 "UPDATE OR IGNORE memory_entities SET entity_id = ?1 WHERE entity_id = ?2",
297 params![target_id, src_id],
298 )?;
299 tx.execute(
300 "DELETE FROM memory_entities WHERE entity_id = ?1",
301 params![src_id],
302 )?;
303 }
304
305 tx.execute(
307 "DELETE FROM memory_entities
308 WHERE rowid NOT IN (
309 SELECT MIN(rowid)
310 FROM memory_entities
311 GROUP BY memory_id, entity_id
312 )",
313 [],
314 )?;
315
316 let mut entities_removed: usize = 0;
319 for &src_id in &source_ids {
320 let removed = tx.execute("DELETE FROM entities WHERE id = ?1", params![src_id])?;
321 entities_removed += removed;
322 }
323
324 let adjacent_ids: Vec<i64> = {
326 let mut stmt = tx.prepare(
327 "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
328 FROM relationships WHERE source_id = ?1 OR target_id = ?1",
329 )?;
330 let ids: Vec<i64> = stmt
331 .query_map(params![target_id], |r| r.get(0))?
332 .collect::<Result<Vec<_>, _>>()?;
333 ids
334 };
335 entities::recalculate_degree(&tx, target_id)?;
336 for &adj_id in &adjacent_ids {
337 entities::recalculate_degree(&tx, adj_id)?;
338 }
339
340 tx.commit()?;
341
342 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
343
344 let response = MergeEntitiesResponse {
345 action: "merged".to_string(),
346 sources: source_names,
347 target: target_name,
348 namespace: namespace.clone(),
349 target_id,
350 relationships_moved,
351 entities_removed,
352 elapsed_ms: inicio.elapsed().as_millis() as u64,
353 };
354
355 match args.format {
356 OutputFormat::Json => output::emit_json(&response)?,
357 OutputFormat::Text | OutputFormat::Markdown => {
358 output::emit_text(&format!(
359 "merged: {} sources into '{}' (relationships_moved={}, entities_removed={}) [{}]",
360 response.sources.len(),
361 response.target,
362 response.relationships_moved,
363 response.entities_removed,
364 response.namespace
365 ));
366 }
367 }
368
369 Ok(())
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
379 fn find_entity_name_by_id_disambiguates_homonyms_across_namespaces() {
380 let conn = rusqlite::Connection::open_in_memory().unwrap();
381 conn.execute_batch(
382 "CREATE TABLE entities (
383 id INTEGER PRIMARY KEY,
384 namespace TEXT NOT NULL,
385 name TEXT NOT NULL,
386 UNIQUE(namespace, name)
387 );",
388 )
389 .unwrap();
390 conn.execute(
391 "INSERT INTO entities (id, namespace, name)
392 VALUES (1, 'ns-a', 'auth'), (2, 'ns-b', 'auth')",
393 [],
394 )
395 .unwrap();
396
397 assert_eq!(
399 find_entity_name_by_id(&conn, "ns-a", 1, true).unwrap(),
400 ("auth".to_string(), "ns-a".to_string())
401 );
402 assert_eq!(
403 find_entity_name_by_id(&conn, "ns-b", 2, true).unwrap(),
404 ("auth".to_string(), "ns-b".to_string())
405 );
406 let err = find_entity_name_by_id(&conn, "ns-a", 2, true).unwrap_err();
407 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
408 assert!(err.to_string().contains("id=2"), "obtido: {err}");
409
410 let (name, ns_actual) = find_entity_name_by_id(&conn, "ns-a", 2, false).unwrap();
413 assert_eq!(name, "auth");
414 assert_eq!(ns_actual, "ns-b");
415 }
416
417 #[test]
418 fn find_entity_name_by_id_missing_id_is_not_found() {
419 let conn = rusqlite::Connection::open_in_memory().unwrap();
420 conn.execute_batch(
421 "CREATE TABLE entities (
422 id INTEGER PRIMARY KEY,
423 namespace TEXT NOT NULL,
424 name TEXT NOT NULL
425 );",
426 )
427 .unwrap();
428 let err = find_entity_name_by_id(&conn, "global", 99, true).unwrap_err();
429 assert_eq!(err.exit_code(), 4);
430 }
431
432 #[derive(clap::Parser)]
435 struct TestCli {
436 #[command(flatten)]
437 args: MergeEntitiesArgs,
438 }
439
440 #[test]
441 fn clap_rejects_names_combined_with_ids() {
442 use clap::Parser;
443 let err =
444 match TestCli::try_parse_from(["t", "--names", "a,b", "--ids", "1,2", "--into", "tgt"])
445 {
446 Ok(_) => panic!("expected argument conflict"),
447 Err(e) => e,
448 };
449 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
450 }
451
452 #[test]
453 fn clap_rejects_into_combined_with_into_id() {
454 use clap::Parser;
455 let err =
456 match TestCli::try_parse_from(["t", "--names", "a", "--into", "tgt", "--into-id", "3"])
457 {
458 Ok(_) => panic!("expected argument conflict"),
459 Err(e) => e,
460 };
461 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
462 }
463
464 #[test]
468 fn self_referential_ids_rejected_before_db() {
469 let args = MergeEntitiesArgs {
470 names: vec![],
471 ids: vec![35575, 35340],
472 into: None,
473 into_id: Some(35340),
474 namespace: Some("global".into()),
475 format: OutputFormat::Json,
476 json: false,
477 db: Some("/nonexistent/no-db.sqlite".into()),
478 cross_namespace: true,
479 };
480 let err = run(args).expect_err("self-ref must fail");
481 assert_eq!(err.exit_code(), 1, "validation exit");
482 let msg = err.to_string();
483 assert!(
484 msg.contains("self-referential") || msg.contains("35340"),
485 "expected self-ref message, got: {msg}"
486 );
487 }
488
489 #[test]
490 fn clap_parses_ids_with_target_in_list() {
491 use clap::Parser;
492 let ok = TestCli::try_parse_from([
493 "t",
494 "--ids",
495 "35575,35340",
496 "--into-id",
497 "35340",
498 "--cross-namespace",
499 ])
500 .expect("must parse");
501 assert_eq!(ok.args.ids, vec![35575, 35340]);
502 assert_eq!(ok.args.into_id, Some(35340));
503 assert!(ok.args.cross_namespace);
504 }
505
506 #[test]
507 fn clap_requires_a_source_and_a_target_selector() {
508 use clap::Parser;
509 assert!(TestCli::try_parse_from(["t", "--into", "tgt"]).is_err());
510 assert!(TestCli::try_parse_from(["t", "--names", "a"]).is_err());
511 let ok = match TestCli::try_parse_from(["t", "--ids", "1,2", "--into-id", "3"]) {
512 Ok(cli) => cli,
513 Err(e) => panic!("expected successful parse: {e}"),
514 };
515 assert_eq!(ok.args.ids, vec![1, 2]);
516 assert_eq!(ok.args.into_id, Some(3));
517 assert!(ok.args.names.is_empty());
518 assert!(ok.args.into.is_none());
519 }
520
521 #[test]
522 fn merge_entities_response_serializes_all_fields() {
523 let resp = MergeEntitiesResponse {
524 action: "merged".to_string(),
525 sources: vec!["auth".to_string(), "authentication".to_string()],
526 target: "auth-service".to_string(),
527 namespace: "global".to_string(),
528 target_id: 1,
529 relationships_moved: 7,
530 entities_removed: 2,
531 elapsed_ms: 15,
532 };
533 let json = serde_json::to_value(&resp).expect("serialization failed");
534 assert_eq!(json["action"], "merged");
535 assert_eq!(json["target"], "auth-service");
536 assert_eq!(json["namespace"], "global");
537 assert_eq!(json["relationships_moved"], 7);
538 assert_eq!(json["entities_removed"], 2);
539 let sources = json["sources"].as_array().expect("must be array");
540 assert_eq!(sources.len(), 2);
541 assert!(json["elapsed_ms"].is_number());
542 }
543
544 #[test]
545 fn merge_entities_response_action_is_merged() {
546 let resp = MergeEntitiesResponse {
547 action: "merged".to_string(),
548 sources: vec!["src".to_string()],
549 target: "tgt".to_string(),
550 namespace: "ns".to_string(),
551 target_id: 1,
552 relationships_moved: 0,
553 entities_removed: 1,
554 elapsed_ms: 0,
555 };
556 assert_eq!(resp.action, "merged");
557 }
558
559 #[test]
560 fn merge_entities_response_empty_sources_serializes() {
561 let resp = MergeEntitiesResponse {
562 action: "merged".to_string(),
563 sources: vec![],
564 target: "target".to_string(),
565 namespace: "global".to_string(),
566 target_id: 1,
567 relationships_moved: 0,
568 entities_removed: 0,
569 elapsed_ms: 1,
570 };
571 let json = serde_json::to_value(&resp).expect("serialization failed");
572 let sources = json["sources"].as_array().expect("must be array");
573 assert_eq!(sources.len(), 0);
574 }
575
576 #[test]
577 fn merge_entities_response_with_zero_relationships_moved() {
578 let resp = MergeEntitiesResponse {
579 action: "merged".to_string(),
580 sources: vec!["src-a".to_string()],
581 target: "tgt".to_string(),
582 namespace: "global".to_string(),
583 target_id: 1,
584 relationships_moved: 0,
585 entities_removed: 1,
586 elapsed_ms: 5,
587 };
588 let json = serde_json::to_value(&resp).expect("serialization failed");
589 assert_eq!(json["relationships_moved"], 0);
590 assert_eq!(json["entities_removed"], 1);
591 }
592
593 #[test]
594 fn merge_entities_response_multiple_sources() {
595 let resp = MergeEntitiesResponse {
596 action: "merged".to_string(),
597 sources: vec!["a".into(), "b".into(), "c".into()],
598 target: "canonical".to_string(),
599 namespace: "proj".to_string(),
600 target_id: 1,
601 relationships_moved: 12,
602 entities_removed: 3,
603 elapsed_ms: 42,
604 };
605 let json = serde_json::to_value(&resp).expect("serialization failed");
606 assert_eq!(json["entities_removed"], 3);
607 let sources = json["sources"].as_array().unwrap();
608 assert_eq!(sources.len(), 3);
609 }
610
611 fn setup_migrated_db_on_disk() -> (tempfile::TempDir, rusqlite::Connection, std::path::PathBuf)
615 {
616 crate::storage::connection::register_vec_extension();
617 let tmp = tempfile::TempDir::new().expect("tempdir");
618 let db_path = tmp.path().join("test.db");
619 let mut conn = rusqlite::Connection::open(&db_path).expect("open");
620 crate::migrations::runner().run(&mut conn).expect("migrate");
621 (tmp, conn, db_path)
622 }
623
624 #[test]
628 fn cross_namespace_merges_source_from_other_namespace() {
629 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
630 conn.execute(
632 "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
633 [],
634 )
635 .unwrap();
636 let tgt_id = conn.last_insert_rowid();
637 conn.execute(
639 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
640 [],
641 )
642 .unwrap();
643 let src_id = conn.last_insert_rowid();
644 conn.execute(
647 "INSERT INTO entities (namespace, name, type) VALUES ('global','third','concept')",
648 [],
649 )
650 .unwrap();
651 let third_id = conn.last_insert_rowid();
652 conn.execute(
653 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
654 VALUES ('ai-sdd', ?1, ?2, 'related', 0.5)",
655 params![src_id, third_id],
656 )
657 .unwrap();
658 drop(conn);
660
661 let args = MergeEntitiesArgs {
662 names: vec![],
663 ids: vec![src_id],
664 into: None,
665 into_id: Some(tgt_id),
666 namespace: Some("global".to_string()),
667 format: OutputFormat::Json,
668 json: false,
669 db: Some(db_path.to_string_lossy().into_owned()),
670 cross_namespace: true,
671 };
672 run(args).expect("cross-namespace merge must succeed");
673
674 let conn = rusqlite::Connection::open(&db_path).unwrap();
676 let src_remaining: i64 = conn
677 .query_row(
678 "SELECT COUNT(*) FROM entities WHERE id = ?1",
679 params![src_id],
680 |r| r.get(0),
681 )
682 .unwrap();
683 assert_eq!(src_remaining, 0, "cross-namespace source must be deleted");
684 let moved: i64 = conn
685 .query_row(
686 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 AND target_id = ?2",
687 params![tgt_id, third_id],
688 |r| r.get(0),
689 )
690 .unwrap();
691 assert!(
692 moved > 0,
693 "relationship must be retargeted to the target entity"
694 );
695 }
696
697 #[test]
701 fn cross_namespace_default_false_rejects_cross_id() {
702 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
703 conn.execute(
704 "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
705 [],
706 )
707 .unwrap();
708 let tgt_id = conn.last_insert_rowid();
709 conn.execute(
710 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
711 [],
712 )
713 .unwrap();
714 let src_id = conn.last_insert_rowid();
715 drop(conn);
716
717 let args = MergeEntitiesArgs {
718 names: vec![],
719 ids: vec![src_id],
720 into: None,
721 into_id: Some(tgt_id),
722 namespace: Some("global".to_string()),
723 format: OutputFormat::Json,
724 json: false,
725 db: Some(db_path.to_string_lossy().into_owned()),
726 cross_namespace: false,
727 };
728 let err = run(args).expect_err("default must reject cross-namespace ID");
729 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
730 }
731
732 #[test]
735 fn cross_namespace_target_must_still_be_in_resolved_namespace() {
736 let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
737 conn.execute(
739 "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','tgt','concept')",
740 [],
741 )
742 .unwrap();
743 let tgt_id = conn.last_insert_rowid();
744 conn.execute(
745 "INSERT INTO entities (namespace, name, type) VALUES ('global','src','concept')",
746 [],
747 )
748 .unwrap();
749 let src_id = conn.last_insert_rowid();
750 drop(conn);
751
752 let args = MergeEntitiesArgs {
753 names: vec![],
754 ids: vec![src_id],
755 into: None,
756 into_id: Some(tgt_id),
757 namespace: Some("global".to_string()),
758 format: OutputFormat::Json,
759 json: false,
760 db: Some(db_path.to_string_lossy().into_owned()),
761 cross_namespace: true,
762 };
763 let err = run(args).expect_err("target in wrong namespace must fail");
764 assert_eq!(
765 err.exit_code(),
766 4,
767 "target must still be NotFound in the resolved namespace"
768 );
769 }
770}