1use crate::embedder::f32_to_bytes;
8use crate::entity_type::EntityType;
9use crate::errors::AppError;
10use crate::parsers::normalize_entity_name;
11use crate::storage::utils::with_busy_retry;
12use rusqlite::{params, Connection};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Serialize, Deserialize, Clone)]
20#[serde(deny_unknown_fields)]
21pub struct NewEntity {
22 pub name: String,
23 #[serde(alias = "type")]
24 pub entity_type: EntityType,
25 pub description: Option<String>,
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone)]
33#[serde(deny_unknown_fields)]
34pub struct NewRelationship {
35 #[serde(alias = "from")]
36 pub source: String,
37 #[serde(alias = "to")]
38 pub target: String,
39 #[serde(alias = "type")]
40 pub relation: String,
41 #[serde(alias = "weight")]
42 pub strength: f64,
43 pub description: Option<String>,
44}
45
46pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
55 if name.len() < 2 {
56 return Err(AppError::Validation(format!(
57 "entity name '{name}' must be at least 2 characters"
58 )));
59 }
60 if name.contains('\n') || name.contains('\r') {
61 return Err(AppError::Validation(
62 "entity name must not contain newline characters".to_string(),
63 ));
64 }
65 if name.len() <= 4
66 && name
67 .chars()
68 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
69 {
70 return Err(AppError::Validation(format!(
71 "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
72 )));
73 }
74 Ok(())
75}
76
77pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
86 validate_entity_name(&e.name)?;
89 let normalized_name = normalize_entity_name(&e.name);
91 if normalized_name.chars().count() < 2 {
94 return Err(AppError::Validation(format!(
95 "entity name '{}' normalizes to '{}' which is too short (minimum 2 characters)",
96 e.name, normalized_name
97 )));
98 }
99 conn.execute(
100 "INSERT INTO entities (namespace, name, type, description)
101 VALUES (?1, ?2, ?3, ?4)
102 ON CONFLICT(namespace, name) DO UPDATE SET
103 type = excluded.type,
104 description = COALESCE(excluded.description, entities.description),
105 updated_at = unixepoch()",
106 params![namespace, normalized_name, e.entity_type, e.description],
107 )?;
108 let id: i64 = conn.query_row(
109 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
110 params![namespace, normalized_name],
111 |r| r.get(0),
112 )?;
113 Ok(id)
114}
115
116pub fn upsert_entity_vec(
128 conn: &Connection,
129 entity_id: i64,
130 namespace: &str,
131 entity_type: EntityType,
132 embedding: &[f32],
133 name: &str,
134) -> Result<(), AppError> {
135 let embedding_bytes = f32_to_bytes(embedding);
138 with_busy_retry(|| {
139 conn.execute(
140 "DELETE FROM vec_entities WHERE entity_id = ?1",
141 params![entity_id],
142 )?;
143 conn.execute(
144 "INSERT INTO vec_entities(entity_id, namespace, type, embedding, name)
145 VALUES (?1, ?2, ?3, ?4, ?5)",
146 params![entity_id, namespace, entity_type, &embedding_bytes, name],
147 )?;
148 Ok(())
149 })
150}
151
152pub fn upsert_relationship(
161 conn: &Connection,
162 namespace: &str,
163 source_id: i64,
164 target_id: i64,
165 rel: &NewRelationship,
166) -> Result<i64, AppError> {
167 conn.execute(
168 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
169 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
170 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
171 weight = excluded.weight,
172 description = COALESCE(excluded.description, relationships.description)",
173 params![
174 namespace,
175 source_id,
176 target_id,
177 rel.relation,
178 rel.strength,
179 rel.description
180 ],
181 )?;
182 let id: i64 = conn.query_row(
183 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
184 params![source_id, target_id, rel.relation],
185 |r| r.get(0),
186 )?;
187 Ok(id)
188}
189
190pub fn link_memory_entity(
196 conn: &Connection,
197 memory_id: i64,
198 entity_id: i64,
199) -> Result<(), AppError> {
200 conn.execute(
201 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
202 params![memory_id, entity_id],
203 )?;
204 Ok(())
205}
206
207pub fn link_memory_relationship(
213 conn: &Connection,
214 memory_id: i64,
215 rel_id: i64,
216) -> Result<(), AppError> {
217 conn.execute(
218 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
219 params![memory_id, rel_id],
220 )?;
221 Ok(())
222}
223
224pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
230 conn.execute(
231 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
232 params![entity_id],
233 )?;
234 Ok(())
235}
236
237pub fn find_entity_id(
243 conn: &Connection,
244 namespace: &str,
245 name: &str,
246) -> Result<Option<i64>, AppError> {
247 let name = normalize_entity_name(name);
253 let mut stmt =
254 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
255 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
256 Ok(id) => Ok(Some(id)),
257 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
258 Err(e) => Err(AppError::Database(e)),
259 }
260}
261
262#[derive(Debug, Serialize)]
264pub struct RelationshipRow {
265 pub id: i64,
266 pub namespace: String,
267 pub source_id: i64,
268 pub target_id: i64,
269 pub relation: String,
270 pub weight: f64,
271 pub description: Option<String>,
272}
273
274pub fn find_relationship(
280 conn: &Connection,
281 source_id: i64,
282 target_id: i64,
283 relation: &str,
284) -> Result<Option<RelationshipRow>, AppError> {
285 let mut stmt = conn.prepare_cached(
286 "SELECT id, namespace, source_id, target_id, relation, weight, description
287 FROM relationships
288 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
289 )?;
290 match stmt.query_row(params![source_id, target_id, relation], |r| {
291 Ok(RelationshipRow {
292 id: r.get(0)?,
293 namespace: r.get(1)?,
294 source_id: r.get(2)?,
295 target_id: r.get(3)?,
296 relation: r.get(4)?,
297 weight: r.get(5)?,
298 description: r.get(6)?,
299 })
300 }) {
301 Ok(row) => Ok(Some(row)),
302 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
303 Err(e) => Err(AppError::Database(e)),
304 }
305}
306
307pub fn create_or_fetch_relationship(
315 conn: &Connection,
316 namespace: &str,
317 source_id: i64,
318 target_id: i64,
319 relation: &str,
320 weight: f64,
321 description: Option<&str>,
322) -> Result<(i64, bool), AppError> {
323 let existing = find_relationship(conn, source_id, target_id, relation)?;
325 if let Some(row) = existing {
326 if (row.weight - weight).abs() > f64::EPSILON {
327 conn.execute(
328 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
329 params![weight, row.id],
330 )?;
331 }
332 return Ok((row.id, false));
333 }
334 conn.execute(
335 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
336 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
337 params![
338 namespace,
339 source_id,
340 target_id,
341 relation,
342 weight,
343 description
344 ],
345 )?;
346 let id: i64 = conn.query_row(
347 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
348 params![source_id, target_id, relation],
349 |r| r.get(0),
350 )?;
351 Ok((id, true))
352}
353
354pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
360 conn.execute(
361 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
362 params![relationship_id],
363 )?;
364 conn.execute(
365 "DELETE FROM relationships WHERE id = ?1",
366 params![relationship_id],
367 )?;
368 Ok(())
369}
370
371pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
377 conn.execute(
378 "UPDATE entities
379 SET degree = (SELECT COUNT(*) FROM relationships
380 WHERE source_id = entities.id OR target_id = entities.id)
381 WHERE id = ?1",
382 params![entity_id],
383 )?;
384 Ok(())
385}
386
387#[derive(Debug, Serialize, Clone)]
389pub struct EntityNode {
390 pub id: i64,
391 pub name: String,
392 pub namespace: String,
393 pub kind: String,
394}
395
396pub fn list_entities(
402 conn: &Connection,
403 namespace: Option<&str>,
404) -> Result<Vec<EntityNode>, AppError> {
405 if let Some(ns) = namespace {
406 let mut stmt = conn.prepare_cached(
407 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
408 )?;
409 let rows = stmt
410 .query_map(params![ns], |r| {
411 Ok(EntityNode {
412 id: r.get(0)?,
413 name: r.get(1)?,
414 namespace: r.get(2)?,
415 kind: r.get(3)?,
416 })
417 })?
418 .collect::<Result<Vec<_>, _>>()?;
419 Ok(rows)
420 } else {
421 let mut stmt = conn.prepare_cached(
422 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
423 )?;
424 let rows = stmt
425 .query_map([], |r| {
426 Ok(EntityNode {
427 id: r.get(0)?,
428 name: r.get(1)?,
429 namespace: r.get(2)?,
430 kind: r.get(3)?,
431 })
432 })?
433 .collect::<Result<Vec<_>, _>>()?;
434 Ok(rows)
435 }
436}
437
438pub fn list_relationships_by_namespace(
444 conn: &Connection,
445 namespace: Option<&str>,
446) -> Result<Vec<RelationshipRow>, AppError> {
447 if let Some(ns) = namespace {
448 let mut stmt = conn.prepare_cached(
449 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
450 FROM relationships r
451 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
452 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
453 ORDER BY r.id",
454 )?;
455 let rows = stmt
456 .query_map(params![ns], |r| {
457 Ok(RelationshipRow {
458 id: r.get(0)?,
459 namespace: r.get(1)?,
460 source_id: r.get(2)?,
461 target_id: r.get(3)?,
462 relation: r.get(4)?,
463 weight: r.get(5)?,
464 description: r.get(6)?,
465 })
466 })?
467 .collect::<Result<Vec<_>, _>>()?;
468 Ok(rows)
469 } else {
470 let mut stmt = conn.prepare_cached(
471 "SELECT id, namespace, source_id, target_id, relation, weight, description
472 FROM relationships ORDER BY id",
473 )?;
474 let rows = stmt
475 .query_map([], |r| {
476 Ok(RelationshipRow {
477 id: r.get(0)?,
478 namespace: r.get(1)?,
479 source_id: r.get(2)?,
480 target_id: r.get(3)?,
481 relation: r.get(4)?,
482 weight: r.get(5)?,
483 description: r.get(6)?,
484 })
485 })?
486 .collect::<Result<Vec<_>, _>>()?;
487 Ok(rows)
488 }
489}
490
491pub fn find_orphan_entity_ids(
497 conn: &Connection,
498 namespace: Option<&str>,
499) -> Result<Vec<i64>, AppError> {
500 if let Some(ns) = namespace {
501 let mut stmt = conn.prepare_cached(
502 "SELECT e.id FROM entities e
503 WHERE e.namespace = ?1
504 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
505 AND NOT EXISTS (
506 SELECT 1 FROM relationships r
507 WHERE r.source_id = e.id OR r.target_id = e.id
508 )",
509 )?;
510 let ids = stmt
511 .query_map(params![ns], |r| r.get::<_, i64>(0))?
512 .collect::<Result<Vec<_>, _>>()?;
513 Ok(ids)
514 } else {
515 let mut stmt = conn.prepare_cached(
516 "SELECT e.id FROM entities e
517 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
518 AND NOT EXISTS (
519 SELECT 1 FROM relationships r
520 WHERE r.source_id = e.id OR r.target_id = e.id
521 )",
522 )?;
523 let ids = stmt
524 .query_map([], |r| r.get::<_, i64>(0))?
525 .collect::<Result<Vec<_>, _>>()?;
526 Ok(ids)
527 }
528}
529
530pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
536 if entity_ids.is_empty() {
537 return Ok(0);
538 }
539 let mut removed = 0usize;
540 for id in entity_ids {
541 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
543 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
544 removed += affected;
545 }
546 Ok(removed)
547}
548
549pub fn count_relationships_by_relation(
558 conn: &Connection,
559 namespace: &str,
560 relation: &str,
561) -> Result<usize, AppError> {
562 let count: i64 = conn.query_row(
563 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
564 params![namespace, relation],
565 |r| r.get(0),
566 )?;
567 Ok(count as usize)
568}
569
570pub fn list_entity_names_by_relation(
579 conn: &Connection,
580 namespace: &str,
581 relation: &str,
582) -> Result<Vec<String>, AppError> {
583 let mut stmt = conn.prepare_cached(
584 "SELECT DISTINCT e.name FROM entities e
585 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
586 WHERE r.namespace = ?1 AND r.relation = ?2
587 ORDER BY e.name",
588 )?;
589 let names: Vec<String> = stmt
590 .query_map(params![namespace, relation], |row| row.get(0))?
591 .collect::<Result<Vec<_>, _>>()?;
592 Ok(names)
593}
594
595pub fn delete_relationships_by_relation(
606 conn: &Connection,
607 namespace: &str,
608 relation: &str,
609) -> Result<(usize, Vec<i64>), AppError> {
610 let mut stmt = conn.prepare_cached(
612 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
613 UNION
614 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
615 )?;
616 let entity_ids: Vec<i64> = stmt
617 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
618 .collect::<Result<Vec<_>, _>>()?;
619
620 let mut id_stmt =
622 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
623 let rel_ids: Vec<i64> = id_stmt
624 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
625 .collect::<Result<Vec<_>, _>>()?;
626
627 let mut total_deleted: usize = 0;
629 for chunk in rel_ids.chunks(1000) {
630 for &rel_id in chunk {
631 conn.execute(
632 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
633 params![rel_id],
634 )?;
635 let affected =
636 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
637 total_deleted += affected;
638 }
639 }
640
641 for &eid in &entity_ids {
643 recalculate_degree(conn, eid)?;
644 }
645
646 Ok((total_deleted, entity_ids))
647}
648
649pub fn knn_search(
656 conn: &Connection,
657 embedding: &[f32],
658 namespace: &str,
659 k: usize,
660) -> Result<Vec<(i64, f32)>, AppError> {
661 let bytes = f32_to_bytes(embedding);
662 let mut stmt = conn.prepare_cached(
663 "SELECT entity_id, distance FROM vec_entities
664 WHERE embedding MATCH ?1 AND namespace = ?2
665 ORDER BY distance LIMIT ?3",
666 )?;
667 let rows = stmt
668 .query_map(params![bytes, namespace, k as i64], |r| {
669 Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
670 })?
671 .collect::<Result<Vec<_>, _>>()?;
672 Ok(rows)
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::constants::EMBEDDING_DIM;
679 use crate::entity_type::EntityType;
680 use crate::storage::connection::register_vec_extension;
681 use rusqlite::Connection;
682 use tempfile::TempDir;
683
684 type TestResult = Result<(), Box<dyn std::error::Error>>;
685
686 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
687 register_vec_extension();
688 let tmp = TempDir::new()?;
689 let db_path = tmp.path().join("test.db");
690 let mut conn = Connection::open(&db_path)?;
691 crate::migrations::runner().run(&mut conn)?;
692 Ok((tmp, conn))
693 }
694
695 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
696 conn.execute(
697 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
698 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
699 [],
700 )?;
701 Ok(conn.last_insert_rowid())
702 }
703
704 fn new_entity_helper(name: &str) -> NewEntity {
705 NewEntity {
706 name: name.to_string(),
707 entity_type: EntityType::Project,
708 description: None,
709 }
710 }
711
712 fn embedding_zero() -> Vec<f32> {
713 vec![0.0f32; EMBEDDING_DIM]
714 }
715
716 #[test]
721 fn test_upsert_entity_creates_new() -> TestResult {
722 let (_tmp, conn) = setup_db()?;
723 let e = new_entity_helper("projeto-alpha");
724 let id = upsert_entity(&conn, "global", &e)?;
725 assert!(id > 0);
726 Ok(())
727 }
728
729 #[test]
730 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
731 let (_tmp, conn) = setup_db()?;
732 let e = new_entity_helper("projeto-beta");
733 let id1 = upsert_entity(&conn, "global", &e)?;
734 let id2 = upsert_entity(&conn, "global", &e)?;
735 assert_eq!(id1, id2);
736 Ok(())
737 }
738
739 #[test]
740 fn test_upsert_entity_updates_description() -> TestResult {
741 let (_tmp, conn) = setup_db()?;
742 let e1 = new_entity_helper("projeto-gamma");
743 let id1 = upsert_entity(&conn, "global", &e1)?;
744
745 let e2 = NewEntity {
746 name: "projeto-gamma".to_string(),
747 entity_type: EntityType::Tool,
748 description: Some("nova desc".to_string()),
749 };
750 let id2 = upsert_entity(&conn, "global", &e2)?;
751 assert_eq!(id1, id2);
752
753 let desc: Option<String> = conn.query_row(
754 "SELECT description FROM entities WHERE id = ?1",
755 params![id1],
756 |r| r.get(0),
757 )?;
758 assert_eq!(desc.as_deref(), Some("nova desc"));
759 Ok(())
760 }
761
762 #[test]
763 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
764 let (_tmp, conn) = setup_db()?;
765 let e = new_entity_helper("compartilhada");
766 let id1 = upsert_entity(&conn, "ns1", &e)?;
767 let id2 = upsert_entity(&conn, "ns2", &e)?;
768 assert_ne!(id1, id2);
769 Ok(())
770 }
771
772 #[test]
777 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
778 let (_tmp, conn) = setup_db()?;
779 let e = new_entity_helper("vec-nova");
780 let entity_id = upsert_entity(&conn, "global", &e)?;
781 let emb = embedding_zero();
782
783 let result = upsert_entity_vec(
784 &conn,
785 entity_id,
786 "global",
787 EntityType::Project,
788 &emb,
789 "vec-nova",
790 );
791 assert!(result.is_ok(), "first insertion must succeed");
792
793 let count: i64 = conn.query_row(
794 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
795 params![entity_id],
796 |r| r.get(0),
797 )?;
798 assert_eq!(count, 1, "must have exactly one row after insertion");
799 Ok(())
800 }
801
802 #[test]
803 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
804 let (_tmp, conn) = setup_db()?;
806 let e = new_entity_helper("vec-existente");
807 let entity_id = upsert_entity(&conn, "global", &e)?;
808 let emb = embedding_zero();
809
810 upsert_entity_vec(
811 &conn,
812 entity_id,
813 "global",
814 EntityType::Project,
815 &emb,
816 "vec-existente",
817 )?;
818
819 let result = upsert_entity_vec(
821 &conn,
822 entity_id,
823 "global",
824 EntityType::Tool,
825 &emb,
826 "vec-existente",
827 );
828 assert!(
829 result.is_ok(),
830 "second insertion (replace) must succeed: {result:?}"
831 );
832
833 let count: i64 = conn.query_row(
834 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
835 params![entity_id],
836 |r| r.get(0),
837 )?;
838 assert_eq!(count, 1, "must have exactly one row after replacement");
839 Ok(())
840 }
841
842 #[test]
843 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
844 let (_tmp, conn) = setup_db()?;
845 let emb = embedding_zero();
846
847 for i in 0..3i64 {
848 let nome = format!("ent-{i}");
849 let e = new_entity_helper(&nome);
850 let entity_id = upsert_entity(&conn, "global", &e)?;
851 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
852 }
853
854 let count: i64 = conn.query_row("SELECT COUNT(*) FROM vec_entities", [], |r| r.get(0))?;
855 assert_eq!(count, 3, "must have three distinct rows in vec_entities");
856 Ok(())
857 }
858
859 #[test]
864 fn test_find_entity_id_existing_returns_some() -> TestResult {
865 let (_tmp, conn) = setup_db()?;
866 let e = new_entity_helper("entidade-busca");
867 let id_inserido = upsert_entity(&conn, "global", &e)?;
868 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
869 assert_eq!(id_encontrado, Some(id_inserido));
870 Ok(())
871 }
872
873 #[test]
874 fn test_find_entity_id_missing_returns_none() -> TestResult {
875 let (_tmp, conn) = setup_db()?;
876 let id = find_entity_id(&conn, "global", "nao-existe")?;
877 assert_eq!(id, None);
878 Ok(())
879 }
880
881 #[test]
886 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
887 let (_tmp, conn) = setup_db()?;
888 let removed = delete_entities_by_ids(&conn, &[])?;
889 assert_eq!(removed, 0);
890 Ok(())
891 }
892
893 #[test]
894 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
895 let (_tmp, conn) = setup_db()?;
896 let e = new_entity_helper("to-delete");
897 let entity_id = upsert_entity(&conn, "global", &e)?;
898
899 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
900 assert_eq!(removed, 1);
901
902 let id = find_entity_id(&conn, "global", "to-delete")?;
903 assert_eq!(id, None, "entity must have been removed");
904 Ok(())
905 }
906
907 #[test]
908 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
909 let (_tmp, conn) = setup_db()?;
910 let removed = delete_entities_by_ids(&conn, &[9999])?;
911 assert_eq!(removed, 0);
912 Ok(())
913 }
914
915 #[test]
916 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
917 let (_tmp, conn) = setup_db()?;
918 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
919 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
920 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
921
922 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
923 assert_eq!(removed, 2);
924
925 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
926 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
927 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
928 let _ = id3;
929 Ok(())
930 }
931
932 #[test]
933 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
934 let (_tmp, conn) = setup_db()?;
935 let e = new_entity_helper("del-com-vec");
936 let entity_id = upsert_entity(&conn, "global", &e)?;
937 let emb = embedding_zero();
938 upsert_entity_vec(
939 &conn,
940 entity_id,
941 "global",
942 EntityType::Project,
943 &emb,
944 "del-com-vec",
945 )?;
946
947 let count_antes: i64 = conn.query_row(
948 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
949 params![entity_id],
950 |r| r.get(0),
951 )?;
952 assert_eq!(count_antes, 1);
953
954 delete_entities_by_ids(&conn, &[entity_id])?;
955
956 let count_depois: i64 = conn.query_row(
957 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
958 params![entity_id],
959 |r| r.get(0),
960 )?;
961 assert_eq!(
962 count_depois, 0,
963 "vec_entities deve ser limpo junto com entities"
964 );
965 Ok(())
966 }
967
968 #[test]
973 fn test_upsert_relationship_creates_new() -> TestResult {
974 let (_tmp, conn) = setup_db()?;
975 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
976 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
977
978 let rel = NewRelationship {
979 source: "rel-a".to_string(),
980 target: "rel-b".to_string(),
981 relation: "uses".to_string(),
982 strength: 0.8,
983 description: None,
984 };
985 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
986 assert!(rel_id > 0);
987 Ok(())
988 }
989
990 #[test]
991 fn test_upsert_relationship_idempotent() -> TestResult {
992 let (_tmp, conn) = setup_db()?;
993 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
994 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
995
996 let rel = NewRelationship {
997 source: "idem-a".to_string(),
998 target: "idem-b".to_string(),
999 relation: "uses".to_string(),
1000 strength: 0.5,
1001 description: None,
1002 };
1003 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1004 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1005 assert_eq!(id1, id2);
1006 Ok(())
1007 }
1008
1009 #[test]
1010 fn test_find_relationship_existing() -> TestResult {
1011 let (_tmp, conn) = setup_db()?;
1012 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1013 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1014
1015 let rel = NewRelationship {
1016 source: "fr-a".to_string(),
1017 target: "fr-b".to_string(),
1018 relation: "depends_on".to_string(),
1019 strength: 0.7,
1020 description: None,
1021 };
1022 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1023
1024 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1025 let row = encontrada.ok_or("relationship should exist")?;
1026 assert_eq!(row.source_id, id_a);
1027 assert_eq!(row.target_id, id_b);
1028 assert!((row.weight - 0.7).abs() < 1e-9);
1029 Ok(())
1030 }
1031
1032 #[test]
1033 fn test_find_relationship_missing_returns_none() -> TestResult {
1034 let (_tmp, conn) = setup_db()?;
1035 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1036 assert!(resultado.is_none());
1037 Ok(())
1038 }
1039
1040 #[test]
1045 fn test_link_memory_entity_idempotent() -> TestResult {
1046 let (_tmp, conn) = setup_db()?;
1047 let memory_id = insert_memory(&conn)?;
1048 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1049
1050 link_memory_entity(&conn, memory_id, entity_id)?;
1051 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1052 assert!(
1053 resultado.is_ok(),
1054 "INSERT OR IGNORE must not fail on duplicate"
1055 );
1056 Ok(())
1057 }
1058
1059 #[test]
1060 fn test_link_memory_relationship_idempotent() -> TestResult {
1061 let (_tmp, conn) = setup_db()?;
1062 let memory_id = insert_memory(&conn)?;
1063 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1064 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1065
1066 let rel = NewRelationship {
1067 source: "mr-a".to_string(),
1068 target: "mr-b".to_string(),
1069 relation: "uses".to_string(),
1070 strength: 0.5,
1071 description: None,
1072 };
1073 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1074
1075 link_memory_relationship(&conn, memory_id, rel_id)?;
1076 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1077 assert!(
1078 resultado.is_ok(),
1079 "INSERT OR IGNORE must not fail on duplicate"
1080 );
1081 Ok(())
1082 }
1083
1084 #[test]
1089 fn test_increment_degree_increases_counter() -> TestResult {
1090 let (_tmp, conn) = setup_db()?;
1091 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1092
1093 increment_degree(&conn, entity_id)?;
1094 increment_degree(&conn, entity_id)?;
1095
1096 let degree: i64 = conn.query_row(
1097 "SELECT degree FROM entities WHERE id = ?1",
1098 params![entity_id],
1099 |r| r.get(0),
1100 )?;
1101 assert_eq!(degree, 2);
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1107 let (_tmp, conn) = setup_db()?;
1108 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1109 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1110 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1111
1112 let rel1 = NewRelationship {
1113 source: "rc-a".to_string(),
1114 target: "rc-b".to_string(),
1115 relation: "uses".to_string(),
1116 strength: 0.5,
1117 description: None,
1118 };
1119 let rel2 = NewRelationship {
1120 source: "rc-c".to_string(),
1121 target: "rc-a".to_string(),
1122 relation: "depends_on".to_string(),
1123 strength: 0.5,
1124 description: None,
1125 };
1126 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1127 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1128
1129 recalculate_degree(&conn, id_a)?;
1130
1131 let degree: i64 = conn.query_row(
1132 "SELECT degree FROM entities WHERE id = ?1",
1133 params![id_a],
1134 |r| r.get(0),
1135 )?;
1136 assert_eq!(
1137 degree, 2,
1138 "rc-a appears in two relationships (source+target)"
1139 );
1140 Ok(())
1141 }
1142
1143 #[test]
1148 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1149 let (_tmp, conn) = setup_db()?;
1150 let memory_id = insert_memory(&conn)?;
1151 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1152 link_memory_entity(&conn, memory_id, entity_id)?;
1153
1154 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1155 assert!(!orfas.contains(&entity_id));
1156 Ok(())
1157 }
1158
1159 #[test]
1160 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1161 let (_tmp, conn) = setup_db()?;
1162 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1163
1164 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1165 assert!(orfas.contains(&entity_id));
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1171 let (_tmp, conn) = setup_db()?;
1172 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1173 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1174
1175 let orfas = find_orphan_entity_ids(&conn, None)?;
1176 assert!(orfas.contains(&id1));
1177 assert!(orfas.contains(&id2));
1178 Ok(())
1179 }
1180
1181 #[test]
1186 fn test_list_entities_with_namespace() -> TestResult {
1187 let (_tmp, conn) = setup_db()?;
1188 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1189 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1190 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1191
1192 let lista = list_entities(&conn, Some("le-ns"))?;
1193 assert_eq!(lista.len(), 2);
1194 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1195 Ok(())
1196 }
1197
1198 #[test]
1199 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1200 let (_tmp, conn) = setup_db()?;
1201 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1202 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1203
1204 let lista = list_entities(&conn, None)?;
1205 assert!(lista.len() >= 2);
1206 Ok(())
1207 }
1208
1209 #[test]
1210 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1211 let (_tmp, conn) = setup_db()?;
1212 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1213 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1214
1215 let rel = NewRelationship {
1216 source: "lr-a".to_string(),
1217 target: "lr-b".to_string(),
1218 relation: "uses".to_string(),
1219 strength: 0.5,
1220 description: None,
1221 };
1222 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1223
1224 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1225 assert!(!lista.is_empty());
1226 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1227 Ok(())
1228 }
1229
1230 #[test]
1235 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1236 let (_tmp, conn) = setup_db()?;
1237 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1238 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1239
1240 let rel = NewRelationship {
1241 source: "dr-a".to_string(),
1242 target: "dr-b".to_string(),
1243 relation: "uses".to_string(),
1244 strength: 0.5,
1245 description: None,
1246 };
1247 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1248
1249 delete_relationship_by_id(&conn, rel_id)?;
1250
1251 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1252 assert!(encontrada.is_none(), "relationship must have been removed");
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1258 let (_tmp, conn) = setup_db()?;
1259 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1260 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1261
1262 let (rel_id, created) =
1263 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1264 assert!(rel_id > 0);
1265 assert!(created);
1266 Ok(())
1267 }
1268
1269 #[test]
1270 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1271 let (_tmp, conn) = setup_db()?;
1272 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1273 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1274
1275 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1276 let (_, created) =
1277 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1278 assert!(
1279 !created,
1280 "second call must return the existing relationship"
1281 );
1282 Ok(())
1283 }
1284
1285 #[test]
1290 fn accepts_type_field_as_alias() -> TestResult {
1291 let json = r#"{"name": "X", "type": "concept"}"#;
1292 let ent: NewEntity = serde_json::from_str(json)?;
1293 assert_eq!(ent.entity_type, EntityType::Concept);
1294 Ok(())
1295 }
1296
1297 #[test]
1298 fn accepts_canonical_entity_type_field() -> TestResult {
1299 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1300 let ent: NewEntity = serde_json::from_str(json)?;
1301 assert_eq!(ent.entity_type, EntityType::Concept);
1302 Ok(())
1303 }
1304
1305 #[test]
1306 fn both_fields_present_yields_duplicate_error() {
1307 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1309 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1310 assert!(
1311 resultado.is_err(),
1312 "both fields in the same JSON are a duplicate"
1313 );
1314 }
1315
1316 #[test]
1317 fn validate_entity_name_accepts_valid() {
1318 assert!(validate_entity_name("rust-lang").is_ok());
1319 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1320 assert!(validate_entity_name("ab").is_ok());
1321 }
1322
1323 #[test]
1324 fn validate_entity_name_rejects_short() {
1325 assert!(validate_entity_name("a").is_err());
1326 assert!(validate_entity_name("").is_err());
1327 }
1328
1329 #[test]
1330 fn validate_entity_name_rejects_newlines() {
1331 assert!(validate_entity_name("foo\nbar").is_err());
1332 assert!(validate_entity_name("foo\rbar").is_err());
1333 }
1334
1335 #[test]
1336 fn validate_entity_name_rejects_short_allcaps() {
1337 assert!(validate_entity_name("RAM").is_err());
1338 assert!(validate_entity_name("NAO").is_err());
1339 assert!(validate_entity_name("OK").is_err());
1340 }
1341
1342 #[test]
1343 fn validate_entity_name_accepts_long_allcaps() {
1344 assert!(validate_entity_name("SQLITE").is_ok());
1345 assert!(validate_entity_name("GRAPHRAG").is_ok());
1346 }
1347
1348 #[test]
1349 fn validate_entity_name_accepts_mixed_case() {
1350 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1353}