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(
127 conn: &Connection,
128 entity_id: i64,
129 namespace: &str,
130 _entity_type: EntityType,
131 embedding: &[f32],
132 _name: &str,
133) -> Result<(), AppError> {
134 let embedding_bytes = f32_to_bytes(embedding);
135 with_busy_retry(|| {
136 conn.execute(
137 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
138 params![entity_id],
139 )?;
140 conn.execute(
141 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
142 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
143 params![
144 entity_id,
145 namespace,
146 &embedding_bytes,
147 "llm-headless",
148 crate::constants::SQLITE_GRAPHRAG_VERSION,
149 crate::constants::EMBEDDING_DIM as i64,
150 ],
151 )?;
152 Ok(())
153 })
154}
155
156pub fn upsert_relationship(
165 conn: &Connection,
166 namespace: &str,
167 source_id: i64,
168 target_id: i64,
169 rel: &NewRelationship,
170) -> Result<i64, AppError> {
171 conn.execute(
172 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
173 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
174 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
175 weight = excluded.weight,
176 description = COALESCE(excluded.description, relationships.description)",
177 params![
178 namespace,
179 source_id,
180 target_id,
181 rel.relation,
182 rel.strength,
183 rel.description
184 ],
185 )?;
186 let id: i64 = conn.query_row(
187 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
188 params![source_id, target_id, rel.relation],
189 |r| r.get(0),
190 )?;
191 Ok(id)
192}
193
194pub fn link_memory_entity(
200 conn: &Connection,
201 memory_id: i64,
202 entity_id: i64,
203) -> Result<(), AppError> {
204 conn.execute(
205 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
206 params![memory_id, entity_id],
207 )?;
208 Ok(())
209}
210
211pub fn link_memory_relationship(
217 conn: &Connection,
218 memory_id: i64,
219 rel_id: i64,
220) -> Result<(), AppError> {
221 conn.execute(
222 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
223 params![memory_id, rel_id],
224 )?;
225 Ok(())
226}
227
228pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
234 conn.execute(
235 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
236 params![entity_id],
237 )?;
238 Ok(())
239}
240
241pub fn find_entity_id(
247 conn: &Connection,
248 namespace: &str,
249 name: &str,
250) -> Result<Option<i64>, AppError> {
251 let name = normalize_entity_name(name);
257 let mut stmt =
258 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
259 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
260 Ok(id) => Ok(Some(id)),
261 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
262 Err(e) => Err(AppError::Database(e)),
263 }
264}
265
266#[derive(Debug, Serialize)]
268pub struct RelationshipRow {
269 pub id: i64,
270 pub namespace: String,
271 pub source_id: i64,
272 pub target_id: i64,
273 pub relation: String,
274 pub weight: f64,
275 pub description: Option<String>,
276}
277
278pub fn find_relationship(
284 conn: &Connection,
285 source_id: i64,
286 target_id: i64,
287 relation: &str,
288) -> Result<Option<RelationshipRow>, AppError> {
289 let mut stmt = conn.prepare_cached(
290 "SELECT id, namespace, source_id, target_id, relation, weight, description
291 FROM relationships
292 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
293 )?;
294 match stmt.query_row(params![source_id, target_id, relation], |r| {
295 Ok(RelationshipRow {
296 id: r.get(0)?,
297 namespace: r.get(1)?,
298 source_id: r.get(2)?,
299 target_id: r.get(3)?,
300 relation: r.get(4)?,
301 weight: r.get(5)?,
302 description: r.get(6)?,
303 })
304 }) {
305 Ok(row) => Ok(Some(row)),
306 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
307 Err(e) => Err(AppError::Database(e)),
308 }
309}
310
311pub fn create_or_fetch_relationship(
319 conn: &Connection,
320 namespace: &str,
321 source_id: i64,
322 target_id: i64,
323 relation: &str,
324 weight: f64,
325 description: Option<&str>,
326) -> Result<(i64, bool), AppError> {
327 let existing = find_relationship(conn, source_id, target_id, relation)?;
329 if let Some(row) = existing {
330 if (row.weight - weight).abs() > f64::EPSILON {
331 conn.execute(
332 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
333 params![weight, row.id],
334 )?;
335 }
336 return Ok((row.id, false));
337 }
338 conn.execute(
339 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
340 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
341 params![
342 namespace,
343 source_id,
344 target_id,
345 relation,
346 weight,
347 description
348 ],
349 )?;
350 let id: i64 = conn.query_row(
351 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
352 params![source_id, target_id, relation],
353 |r| r.get(0),
354 )?;
355 Ok((id, true))
356}
357
358pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
364 conn.execute(
365 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
366 params![relationship_id],
367 )?;
368 conn.execute(
369 "DELETE FROM relationships WHERE id = ?1",
370 params![relationship_id],
371 )?;
372 Ok(())
373}
374
375pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
381 conn.execute(
382 "UPDATE entities
383 SET degree = (SELECT COUNT(*) FROM relationships
384 WHERE source_id = entities.id OR target_id = entities.id)
385 WHERE id = ?1",
386 params![entity_id],
387 )?;
388 Ok(())
389}
390
391#[derive(Debug, Serialize, Clone)]
393pub struct EntityNode {
394 pub id: i64,
395 pub name: String,
396 pub namespace: String,
397 pub kind: String,
398}
399
400pub fn list_entities(
406 conn: &Connection,
407 namespace: Option<&str>,
408) -> Result<Vec<EntityNode>, AppError> {
409 if let Some(ns) = namespace {
410 let mut stmt = conn.prepare_cached(
411 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
412 )?;
413 let rows = stmt
414 .query_map(params![ns], |r| {
415 Ok(EntityNode {
416 id: r.get(0)?,
417 name: r.get(1)?,
418 namespace: r.get(2)?,
419 kind: r.get(3)?,
420 })
421 })?
422 .collect::<Result<Vec<_>, _>>()?;
423 Ok(rows)
424 } else {
425 let mut stmt = conn.prepare_cached(
426 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
427 )?;
428 let rows = stmt
429 .query_map([], |r| {
430 Ok(EntityNode {
431 id: r.get(0)?,
432 name: r.get(1)?,
433 namespace: r.get(2)?,
434 kind: r.get(3)?,
435 })
436 })?
437 .collect::<Result<Vec<_>, _>>()?;
438 Ok(rows)
439 }
440}
441
442pub fn list_relationships_by_namespace(
448 conn: &Connection,
449 namespace: Option<&str>,
450) -> Result<Vec<RelationshipRow>, AppError> {
451 if let Some(ns) = namespace {
452 let mut stmt = conn.prepare_cached(
453 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
454 FROM relationships r
455 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
456 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
457 ORDER BY r.id",
458 )?;
459 let rows = stmt
460 .query_map(params![ns], |r| {
461 Ok(RelationshipRow {
462 id: r.get(0)?,
463 namespace: r.get(1)?,
464 source_id: r.get(2)?,
465 target_id: r.get(3)?,
466 relation: r.get(4)?,
467 weight: r.get(5)?,
468 description: r.get(6)?,
469 })
470 })?
471 .collect::<Result<Vec<_>, _>>()?;
472 Ok(rows)
473 } else {
474 let mut stmt = conn.prepare_cached(
475 "SELECT id, namespace, source_id, target_id, relation, weight, description
476 FROM relationships ORDER BY id",
477 )?;
478 let rows = stmt
479 .query_map([], |r| {
480 Ok(RelationshipRow {
481 id: r.get(0)?,
482 namespace: r.get(1)?,
483 source_id: r.get(2)?,
484 target_id: r.get(3)?,
485 relation: r.get(4)?,
486 weight: r.get(5)?,
487 description: r.get(6)?,
488 })
489 })?
490 .collect::<Result<Vec<_>, _>>()?;
491 Ok(rows)
492 }
493}
494
495pub fn find_orphan_entity_ids(
501 conn: &Connection,
502 namespace: Option<&str>,
503) -> Result<Vec<i64>, AppError> {
504 if let Some(ns) = namespace {
505 let mut stmt = conn.prepare_cached(
506 "SELECT e.id FROM entities e
507 WHERE e.namespace = ?1
508 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
509 AND NOT EXISTS (
510 SELECT 1 FROM relationships r
511 WHERE r.source_id = e.id OR r.target_id = e.id
512 )",
513 )?;
514 let ids = stmt
515 .query_map(params![ns], |r| r.get::<_, i64>(0))?
516 .collect::<Result<Vec<_>, _>>()?;
517 Ok(ids)
518 } else {
519 let mut stmt = conn.prepare_cached(
520 "SELECT e.id FROM entities e
521 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
522 AND NOT EXISTS (
523 SELECT 1 FROM relationships r
524 WHERE r.source_id = e.id OR r.target_id = e.id
525 )",
526 )?;
527 let ids = stmt
528 .query_map([], |r| r.get::<_, i64>(0))?
529 .collect::<Result<Vec<_>, _>>()?;
530 Ok(ids)
531 }
532}
533
534pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
540 if entity_ids.is_empty() {
541 return Ok(0);
542 }
543 let mut removed = 0usize;
544 for id in entity_ids {
545 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
547 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
548 removed += affected;
549 }
550 Ok(removed)
551}
552
553pub fn count_relationships_by_relation(
562 conn: &Connection,
563 namespace: &str,
564 relation: &str,
565) -> Result<usize, AppError> {
566 let count: i64 = conn.query_row(
567 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
568 params![namespace, relation],
569 |r| r.get(0),
570 )?;
571 Ok(count as usize)
572}
573
574pub fn list_entity_names_by_relation(
583 conn: &Connection,
584 namespace: &str,
585 relation: &str,
586) -> Result<Vec<String>, AppError> {
587 let mut stmt = conn.prepare_cached(
588 "SELECT DISTINCT e.name FROM entities e
589 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
590 WHERE r.namespace = ?1 AND r.relation = ?2
591 ORDER BY e.name",
592 )?;
593 let names: Vec<String> = stmt
594 .query_map(params![namespace, relation], |row| row.get(0))?
595 .collect::<Result<Vec<_>, _>>()?;
596 Ok(names)
597}
598
599pub fn delete_relationships_by_relation(
610 conn: &Connection,
611 namespace: &str,
612 relation: &str,
613) -> Result<(usize, Vec<i64>), AppError> {
614 let mut stmt = conn.prepare_cached(
616 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
617 UNION
618 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
619 )?;
620 let entity_ids: Vec<i64> = stmt
621 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
622 .collect::<Result<Vec<_>, _>>()?;
623
624 let mut id_stmt =
626 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
627 let rel_ids: Vec<i64> = id_stmt
628 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
629 .collect::<Result<Vec<_>, _>>()?;
630
631 let mut total_deleted: usize = 0;
633 for chunk in rel_ids.chunks(1000) {
634 for &rel_id in chunk {
635 conn.execute(
636 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
637 params![rel_id],
638 )?;
639 let affected =
640 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
641 total_deleted += affected;
642 }
643 }
644
645 for &eid in &entity_ids {
647 recalculate_degree(conn, eid)?;
648 }
649
650 Ok((total_deleted, entity_ids))
651}
652
653pub fn knn_search(
666 conn: &Connection,
667 embedding: &[f32],
668 namespace: &str,
669 k: usize,
670) -> Result<Vec<(i64, f32)>, AppError> {
671 if embedding.len() != crate::constants::EMBEDDING_DIM {
672 return Err(AppError::Embedding(format!(
673 "knn_search embedding has {} dims, expected {}",
674 embedding.len(),
675 crate::constants::EMBEDDING_DIM
676 )));
677 }
678 let mut stmt = conn.prepare_cached(
679 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
680 )?;
681 let mut scored: Vec<(i64, f32)> = stmt
682 .query_map(params![namespace], |r| {
683 let id: i64 = r.get(0)?;
684 let bytes: Vec<u8> = r.get(1)?;
685 Ok((id, bytes))
686 })?
687 .filter_map(|row| {
688 row.ok().and_then(|(id, bytes)| {
689 let stored = crate::embedder::bytes_to_f32(&bytes);
690 if stored.len() != embedding.len() {
691 return None;
692 }
693 let score = crate::similarity::cosine_similarity(embedding, &stored);
694 Some((id, score))
695 })
696 })
697 .collect();
698 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
701 scored.truncate(k);
702 Ok(scored)
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use crate::constants::EMBEDDING_DIM;
709 use crate::entity_type::EntityType;
710 use crate::storage::connection::register_vec_extension;
711 use rusqlite::Connection;
712 use tempfile::TempDir;
713
714 type TestResult = Result<(), Box<dyn std::error::Error>>;
715
716 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
717 register_vec_extension();
718 let tmp = TempDir::new()?;
719 let db_path = tmp.path().join("test.db");
720 let mut conn = Connection::open(&db_path)?;
721 crate::migrations::runner().run(&mut conn)?;
722 Ok((tmp, conn))
723 }
724
725 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
726 conn.execute(
727 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
728 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
729 [],
730 )?;
731 Ok(conn.last_insert_rowid())
732 }
733
734 fn new_entity_helper(name: &str) -> NewEntity {
735 NewEntity {
736 name: name.to_string(),
737 entity_type: EntityType::Project,
738 description: None,
739 }
740 }
741
742 fn embedding_zero() -> Vec<f32> {
743 vec![0.0f32; EMBEDDING_DIM]
744 }
745
746 #[test]
751 fn test_upsert_entity_creates_new() -> TestResult {
752 let (_tmp, conn) = setup_db()?;
753 let e = new_entity_helper("projeto-alpha");
754 let id = upsert_entity(&conn, "global", &e)?;
755 assert!(id > 0);
756 Ok(())
757 }
758
759 #[test]
760 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
761 let (_tmp, conn) = setup_db()?;
762 let e = new_entity_helper("projeto-beta");
763 let id1 = upsert_entity(&conn, "global", &e)?;
764 let id2 = upsert_entity(&conn, "global", &e)?;
765 assert_eq!(id1, id2);
766 Ok(())
767 }
768
769 #[test]
770 fn test_upsert_entity_updates_description() -> TestResult {
771 let (_tmp, conn) = setup_db()?;
772 let e1 = new_entity_helper("projeto-gamma");
773 let id1 = upsert_entity(&conn, "global", &e1)?;
774
775 let e2 = NewEntity {
776 name: "projeto-gamma".to_string(),
777 entity_type: EntityType::Tool,
778 description: Some("nova desc".to_string()),
779 };
780 let id2 = upsert_entity(&conn, "global", &e2)?;
781 assert_eq!(id1, id2);
782
783 let desc: Option<String> = conn.query_row(
784 "SELECT description FROM entities WHERE id = ?1",
785 params![id1],
786 |r| r.get(0),
787 )?;
788 assert_eq!(desc.as_deref(), Some("nova desc"));
789 Ok(())
790 }
791
792 #[test]
793 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
794 let (_tmp, conn) = setup_db()?;
795 let e = new_entity_helper("compartilhada");
796 let id1 = upsert_entity(&conn, "ns1", &e)?;
797 let id2 = upsert_entity(&conn, "ns2", &e)?;
798 assert_ne!(id1, id2);
799 Ok(())
800 }
801
802 #[test]
807 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
808 let (_tmp, conn) = setup_db()?;
809 let e = new_entity_helper("vec-nova");
810 let entity_id = upsert_entity(&conn, "global", &e)?;
811 let emb = embedding_zero();
812
813 let result = upsert_entity_vec(
814 &conn,
815 entity_id,
816 "global",
817 EntityType::Project,
818 &emb,
819 "vec-nova",
820 );
821 assert!(result.is_ok(), "first insertion must succeed");
822
823 let count: i64 = conn.query_row(
824 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
825 params![entity_id],
826 |r| r.get(0),
827 )?;
828 assert_eq!(count, 1, "must have exactly one row after insertion");
829 Ok(())
830 }
831
832 #[test]
833 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
834 let (_tmp, conn) = setup_db()?;
836 let e = new_entity_helper("vec-existente");
837 let entity_id = upsert_entity(&conn, "global", &e)?;
838 let emb = embedding_zero();
839
840 upsert_entity_vec(
841 &conn,
842 entity_id,
843 "global",
844 EntityType::Project,
845 &emb,
846 "vec-existente",
847 )?;
848
849 let result = upsert_entity_vec(
851 &conn,
852 entity_id,
853 "global",
854 EntityType::Tool,
855 &emb,
856 "vec-existente",
857 );
858 assert!(
859 result.is_ok(),
860 "second insertion (replace) must succeed: {result:?}"
861 );
862
863 let count: i64 = conn.query_row(
864 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
865 params![entity_id],
866 |r| r.get(0),
867 )?;
868 assert_eq!(count, 1, "must have exactly one row after replacement");
869 Ok(())
870 }
871
872 #[test]
873 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
874 let (_tmp, conn) = setup_db()?;
875 let emb = embedding_zero();
876
877 for i in 0..3i64 {
878 let nome = format!("ent-{i}");
879 let e = new_entity_helper(&nome);
880 let entity_id = upsert_entity(&conn, "global", &e)?;
881 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
882 }
883
884 let count: i64 =
885 conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
886 assert_eq!(
887 count, 3,
888 "must have three distinct rows in entity_embeddings"
889 );
890 Ok(())
891 }
892
893 #[test]
898 fn test_find_entity_id_existing_returns_some() -> TestResult {
899 let (_tmp, conn) = setup_db()?;
900 let e = new_entity_helper("entidade-busca");
901 let id_inserido = upsert_entity(&conn, "global", &e)?;
902 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
903 assert_eq!(id_encontrado, Some(id_inserido));
904 Ok(())
905 }
906
907 #[test]
908 fn test_find_entity_id_missing_returns_none() -> TestResult {
909 let (_tmp, conn) = setup_db()?;
910 let id = find_entity_id(&conn, "global", "nao-existe")?;
911 assert_eq!(id, None);
912 Ok(())
913 }
914
915 #[test]
920 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
921 let (_tmp, conn) = setup_db()?;
922 let removed = delete_entities_by_ids(&conn, &[])?;
923 assert_eq!(removed, 0);
924 Ok(())
925 }
926
927 #[test]
928 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
929 let (_tmp, conn) = setup_db()?;
930 let e = new_entity_helper("to-delete");
931 let entity_id = upsert_entity(&conn, "global", &e)?;
932
933 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
934 assert_eq!(removed, 1);
935
936 let id = find_entity_id(&conn, "global", "to-delete")?;
937 assert_eq!(id, None, "entity must have been removed");
938 Ok(())
939 }
940
941 #[test]
942 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
943 let (_tmp, conn) = setup_db()?;
944 let removed = delete_entities_by_ids(&conn, &[9999])?;
945 assert_eq!(removed, 0);
946 Ok(())
947 }
948
949 #[test]
950 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
951 let (_tmp, conn) = setup_db()?;
952 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
953 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
954 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
955
956 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
957 assert_eq!(removed, 2);
958
959 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
960 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
961 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
962 let _ = id3;
963 Ok(())
964 }
965
966 #[test]
967 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
968 let (_tmp, conn) = setup_db()?;
969 let e = new_entity_helper("del-com-vec");
970 let entity_id = upsert_entity(&conn, "global", &e)?;
971 let emb = embedding_zero();
972 upsert_entity_vec(
973 &conn,
974 entity_id,
975 "global",
976 EntityType::Project,
977 &emb,
978 "del-com-vec",
979 )?;
980
981 let count_antes: i64 = conn.query_row(
982 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
983 params![entity_id],
984 |r| r.get(0),
985 )?;
986 assert_eq!(count_antes, 1);
987
988 delete_entities_by_ids(&conn, &[entity_id])?;
989
990 let count_depois: i64 = conn.query_row(
991 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
992 params![entity_id],
993 |r| r.get(0),
994 )?;
995 assert_eq!(
996 count_depois, 0,
997 "entity_embeddings deve ser limpo junto com entities"
998 );
999 Ok(())
1000 }
1001
1002 #[test]
1007 fn test_upsert_relationship_creates_new() -> TestResult {
1008 let (_tmp, conn) = setup_db()?;
1009 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1010 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1011
1012 let rel = NewRelationship {
1013 source: "rel-a".to_string(),
1014 target: "rel-b".to_string(),
1015 relation: "uses".to_string(),
1016 strength: 0.8,
1017 description: None,
1018 };
1019 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1020 assert!(rel_id > 0);
1021 Ok(())
1022 }
1023
1024 #[test]
1025 fn test_upsert_relationship_idempotent() -> TestResult {
1026 let (_tmp, conn) = setup_db()?;
1027 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1028 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1029
1030 let rel = NewRelationship {
1031 source: "idem-a".to_string(),
1032 target: "idem-b".to_string(),
1033 relation: "uses".to_string(),
1034 strength: 0.5,
1035 description: None,
1036 };
1037 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1038 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1039 assert_eq!(id1, id2);
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn test_find_relationship_existing() -> TestResult {
1045 let (_tmp, conn) = setup_db()?;
1046 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1047 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1048
1049 let rel = NewRelationship {
1050 source: "fr-a".to_string(),
1051 target: "fr-b".to_string(),
1052 relation: "depends_on".to_string(),
1053 strength: 0.7,
1054 description: None,
1055 };
1056 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1057
1058 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1059 let row = encontrada.ok_or("relationship should exist")?;
1060 assert_eq!(row.source_id, id_a);
1061 assert_eq!(row.target_id, id_b);
1062 assert!((row.weight - 0.7).abs() < 1e-9);
1063 Ok(())
1064 }
1065
1066 #[test]
1067 fn test_find_relationship_missing_returns_none() -> TestResult {
1068 let (_tmp, conn) = setup_db()?;
1069 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1070 assert!(resultado.is_none());
1071 Ok(())
1072 }
1073
1074 #[test]
1079 fn test_link_memory_entity_idempotent() -> TestResult {
1080 let (_tmp, conn) = setup_db()?;
1081 let memory_id = insert_memory(&conn)?;
1082 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1083
1084 link_memory_entity(&conn, memory_id, entity_id)?;
1085 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1086 assert!(
1087 resultado.is_ok(),
1088 "INSERT OR IGNORE must not fail on duplicate"
1089 );
1090 Ok(())
1091 }
1092
1093 #[test]
1094 fn test_link_memory_relationship_idempotent() -> TestResult {
1095 let (_tmp, conn) = setup_db()?;
1096 let memory_id = insert_memory(&conn)?;
1097 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1098 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1099
1100 let rel = NewRelationship {
1101 source: "mr-a".to_string(),
1102 target: "mr-b".to_string(),
1103 relation: "uses".to_string(),
1104 strength: 0.5,
1105 description: None,
1106 };
1107 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1108
1109 link_memory_relationship(&conn, memory_id, rel_id)?;
1110 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1111 assert!(
1112 resultado.is_ok(),
1113 "INSERT OR IGNORE must not fail on duplicate"
1114 );
1115 Ok(())
1116 }
1117
1118 #[test]
1123 fn test_increment_degree_increases_counter() -> TestResult {
1124 let (_tmp, conn) = setup_db()?;
1125 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1126
1127 increment_degree(&conn, entity_id)?;
1128 increment_degree(&conn, entity_id)?;
1129
1130 let degree: i64 = conn.query_row(
1131 "SELECT degree FROM entities WHERE id = ?1",
1132 params![entity_id],
1133 |r| r.get(0),
1134 )?;
1135 assert_eq!(degree, 2);
1136 Ok(())
1137 }
1138
1139 #[test]
1140 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1141 let (_tmp, conn) = setup_db()?;
1142 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1143 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1144 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1145
1146 let rel1 = NewRelationship {
1147 source: "rc-a".to_string(),
1148 target: "rc-b".to_string(),
1149 relation: "uses".to_string(),
1150 strength: 0.5,
1151 description: None,
1152 };
1153 let rel2 = NewRelationship {
1154 source: "rc-c".to_string(),
1155 target: "rc-a".to_string(),
1156 relation: "depends_on".to_string(),
1157 strength: 0.5,
1158 description: None,
1159 };
1160 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1161 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1162
1163 recalculate_degree(&conn, id_a)?;
1164
1165 let degree: i64 = conn.query_row(
1166 "SELECT degree FROM entities WHERE id = ?1",
1167 params![id_a],
1168 |r| r.get(0),
1169 )?;
1170 assert_eq!(
1171 degree, 2,
1172 "rc-a appears in two relationships (source+target)"
1173 );
1174 Ok(())
1175 }
1176
1177 #[test]
1182 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1183 let (_tmp, conn) = setup_db()?;
1184 let memory_id = insert_memory(&conn)?;
1185 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1186 link_memory_entity(&conn, memory_id, entity_id)?;
1187
1188 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1189 assert!(!orfas.contains(&entity_id));
1190 Ok(())
1191 }
1192
1193 #[test]
1194 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1195 let (_tmp, conn) = setup_db()?;
1196 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1197
1198 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1199 assert!(orfas.contains(&entity_id));
1200 Ok(())
1201 }
1202
1203 #[test]
1204 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1205 let (_tmp, conn) = setup_db()?;
1206 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1207 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1208
1209 let orfas = find_orphan_entity_ids(&conn, None)?;
1210 assert!(orfas.contains(&id1));
1211 assert!(orfas.contains(&id2));
1212 Ok(())
1213 }
1214
1215 #[test]
1220 fn test_list_entities_with_namespace() -> TestResult {
1221 let (_tmp, conn) = setup_db()?;
1222 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1223 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1224 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1225
1226 let lista = list_entities(&conn, Some("le-ns"))?;
1227 assert_eq!(lista.len(), 2);
1228 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1229 Ok(())
1230 }
1231
1232 #[test]
1233 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1234 let (_tmp, conn) = setup_db()?;
1235 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1236 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1237
1238 let lista = list_entities(&conn, None)?;
1239 assert!(lista.len() >= 2);
1240 Ok(())
1241 }
1242
1243 #[test]
1244 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1245 let (_tmp, conn) = setup_db()?;
1246 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1247 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1248
1249 let rel = NewRelationship {
1250 source: "lr-a".to_string(),
1251 target: "lr-b".to_string(),
1252 relation: "uses".to_string(),
1253 strength: 0.5,
1254 description: None,
1255 };
1256 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1257
1258 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1259 assert!(!lista.is_empty());
1260 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1261 Ok(())
1262 }
1263
1264 #[test]
1269 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1270 let (_tmp, conn) = setup_db()?;
1271 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1272 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1273
1274 let rel = NewRelationship {
1275 source: "dr-a".to_string(),
1276 target: "dr-b".to_string(),
1277 relation: "uses".to_string(),
1278 strength: 0.5,
1279 description: None,
1280 };
1281 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1282
1283 delete_relationship_by_id(&conn, rel_id)?;
1284
1285 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1286 assert!(encontrada.is_none(), "relationship must have been removed");
1287 Ok(())
1288 }
1289
1290 #[test]
1291 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1292 let (_tmp, conn) = setup_db()?;
1293 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1294 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1295
1296 let (rel_id, created) =
1297 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1298 assert!(rel_id > 0);
1299 assert!(created);
1300 Ok(())
1301 }
1302
1303 #[test]
1304 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1305 let (_tmp, conn) = setup_db()?;
1306 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1307 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1308
1309 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1310 let (_, created) =
1311 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1312 assert!(
1313 !created,
1314 "second call must return the existing relationship"
1315 );
1316 Ok(())
1317 }
1318
1319 #[test]
1324 fn accepts_type_field_as_alias() -> TestResult {
1325 let json = r#"{"name": "X", "type": "concept"}"#;
1326 let ent: NewEntity = serde_json::from_str(json)?;
1327 assert_eq!(ent.entity_type, EntityType::Concept);
1328 Ok(())
1329 }
1330
1331 #[test]
1332 fn accepts_canonical_entity_type_field() -> TestResult {
1333 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1334 let ent: NewEntity = serde_json::from_str(json)?;
1335 assert_eq!(ent.entity_type, EntityType::Concept);
1336 Ok(())
1337 }
1338
1339 #[test]
1340 fn both_fields_present_yields_duplicate_error() {
1341 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1343 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1344 assert!(
1345 resultado.is_err(),
1346 "both fields in the same JSON are a duplicate"
1347 );
1348 }
1349
1350 #[test]
1351 fn validate_entity_name_accepts_valid() {
1352 assert!(validate_entity_name("rust-lang").is_ok());
1353 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1354 assert!(validate_entity_name("ab").is_ok());
1355 }
1356
1357 #[test]
1358 fn validate_entity_name_rejects_short() {
1359 assert!(validate_entity_name("a").is_err());
1360 assert!(validate_entity_name("").is_err());
1361 }
1362
1363 #[test]
1364 fn validate_entity_name_rejects_newlines() {
1365 assert!(validate_entity_name("foo\nbar").is_err());
1366 assert!(validate_entity_name("foo\rbar").is_err());
1367 }
1368
1369 #[test]
1370 fn validate_entity_name_rejects_short_allcaps() {
1371 assert!(validate_entity_name("RAM").is_err());
1372 assert!(validate_entity_name("NAO").is_err());
1373 assert!(validate_entity_name("OK").is_err());
1374 }
1375
1376 #[test]
1377 fn validate_entity_name_accepts_long_allcaps() {
1378 assert!(validate_entity_name("SQLITE").is_ok());
1379 assert!(validate_entity_name("GRAPHRAG").is_ok());
1380 }
1381
1382 #[test]
1383 fn validate_entity_name_accepts_mixed_case() {
1384 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1387}