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 if embedding.is_empty() {
139 tracing::debug!(
140 entity_id,
141 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
142 );
143 return Ok(());
144 }
145 let embedding_bytes = f32_to_bytes(embedding);
146 with_busy_retry(|| {
147 conn.execute(
148 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
149 params![entity_id],
150 )?;
151 conn.execute(
152 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
153 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
154 params![
155 entity_id,
156 namespace,
157 &embedding_bytes,
158 "llm-headless",
159 crate::constants::SQLITE_GRAPHRAG_VERSION,
160 crate::constants::embedding_dim() as i64,
161 ],
162 )?;
163 Ok(())
164 })
165}
166
167pub fn upsert_relationship(
176 conn: &Connection,
177 namespace: &str,
178 source_id: i64,
179 target_id: i64,
180 rel: &NewRelationship,
181) -> Result<i64, AppError> {
182 conn.execute(
183 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
184 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
185 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
186 weight = excluded.weight,
187 description = COALESCE(excluded.description, relationships.description)",
188 params![
189 namespace,
190 source_id,
191 target_id,
192 rel.relation,
193 rel.strength,
194 rel.description
195 ],
196 )?;
197 let id: i64 = conn.query_row(
198 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
199 params![source_id, target_id, rel.relation],
200 |r| r.get(0),
201 )?;
202 Ok(id)
203}
204
205pub fn link_memory_entity(
211 conn: &Connection,
212 memory_id: i64,
213 entity_id: i64,
214) -> Result<(), AppError> {
215 conn.execute(
216 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
217 params![memory_id, entity_id],
218 )?;
219 Ok(())
220}
221
222pub fn link_memory_relationship(
228 conn: &Connection,
229 memory_id: i64,
230 rel_id: i64,
231) -> Result<(), AppError> {
232 conn.execute(
233 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
234 params![memory_id, rel_id],
235 )?;
236 Ok(())
237}
238
239pub fn unlink_memory_entity(
249 conn: &Connection,
250 memory_id: i64,
251 entity_id: i64,
252) -> Result<u64, AppError> {
253 let affected = conn.execute(
254 "DELETE FROM memory_entities WHERE memory_id = ?1 AND entity_id = ?2",
255 params![memory_id, entity_id],
256 )?;
257 Ok(affected as u64)
258}
259
260pub fn clear_memory_graph_bindings(
270 conn: &Connection,
271 memory_id: i64,
272) -> Result<(u64, u64), AppError> {
273 let entities_removed = conn.execute(
274 "DELETE FROM memory_entities WHERE memory_id = ?1",
275 params![memory_id],
276 )? as u64;
277 let rels_removed = conn.execute(
278 "DELETE FROM memory_relationships WHERE memory_id = ?1",
279 params![memory_id],
280 )? as u64;
281 Ok((entities_removed, rels_removed))
282}
283
284pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
290 conn.execute(
291 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
292 params![entity_id],
293 )?;
294 Ok(())
295}
296
297pub fn find_entity_id(
303 conn: &Connection,
304 namespace: &str,
305 name: &str,
306) -> Result<Option<i64>, AppError> {
307 let name = normalize_entity_name(name);
313 let mut stmt =
314 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
315 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
316 Ok(id) => Ok(Some(id)),
317 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
318 Err(e) => Err(AppError::Database(e)),
319 }
320}
321
322#[derive(Debug, Serialize)]
324pub struct RelationshipRow {
325 pub id: i64,
326 pub namespace: String,
327 pub source_id: i64,
328 pub target_id: i64,
329 pub relation: String,
330 pub weight: f64,
331 pub description: Option<String>,
332}
333
334pub fn find_relationship(
340 conn: &Connection,
341 source_id: i64,
342 target_id: i64,
343 relation: &str,
344) -> Result<Option<RelationshipRow>, AppError> {
345 let mut stmt = conn.prepare_cached(
346 "SELECT id, namespace, source_id, target_id, relation, weight, description
347 FROM relationships
348 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
349 )?;
350 match stmt.query_row(params![source_id, target_id, relation], |r| {
351 Ok(RelationshipRow {
352 id: r.get(0)?,
353 namespace: r.get(1)?,
354 source_id: r.get(2)?,
355 target_id: r.get(3)?,
356 relation: r.get(4)?,
357 weight: r.get(5)?,
358 description: r.get(6)?,
359 })
360 }) {
361 Ok(row) => Ok(Some(row)),
362 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
363 Err(e) => Err(AppError::Database(e)),
364 }
365}
366
367pub fn create_or_fetch_relationship(
375 conn: &Connection,
376 namespace: &str,
377 source_id: i64,
378 target_id: i64,
379 relation: &str,
380 weight: f64,
381 description: Option<&str>,
382) -> Result<(i64, bool), AppError> {
383 let existing = find_relationship(conn, source_id, target_id, relation)?;
385 if let Some(row) = existing {
386 if (row.weight - weight).abs() > f64::EPSILON {
387 conn.execute(
388 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
389 params![weight, row.id],
390 )?;
391 }
392 return Ok((row.id, false));
393 }
394 conn.execute(
395 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
396 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
397 params![
398 namespace,
399 source_id,
400 target_id,
401 relation,
402 weight,
403 description
404 ],
405 )?;
406 let id: i64 = conn.query_row(
407 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
408 params![source_id, target_id, relation],
409 |r| r.get(0),
410 )?;
411 Ok((id, true))
412}
413
414pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
420 conn.execute(
421 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
422 params![relationship_id],
423 )?;
424 conn.execute(
425 "DELETE FROM relationships WHERE id = ?1",
426 params![relationship_id],
427 )?;
428 Ok(())
429}
430
431pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
437 conn.execute(
438 "UPDATE entities
439 SET degree = (SELECT COUNT(*) FROM relationships
440 WHERE source_id = entities.id OR target_id = entities.id)
441 WHERE id = ?1",
442 params![entity_id],
443 )?;
444 Ok(())
445}
446
447#[derive(Debug, Serialize, Clone)]
449pub struct EntityNode {
450 pub id: i64,
451 pub name: String,
452 pub namespace: String,
453 pub kind: String,
454}
455
456pub fn list_entities(
462 conn: &Connection,
463 namespace: Option<&str>,
464) -> Result<Vec<EntityNode>, AppError> {
465 if let Some(ns) = namespace {
466 let mut stmt = conn.prepare_cached(
467 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
468 )?;
469 let rows = stmt
470 .query_map(params![ns], |r| {
471 Ok(EntityNode {
472 id: r.get(0)?,
473 name: r.get(1)?,
474 namespace: r.get(2)?,
475 kind: r.get(3)?,
476 })
477 })?
478 .collect::<Result<Vec<_>, _>>()?;
479 Ok(rows)
480 } else {
481 let mut stmt = conn.prepare_cached(
482 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
483 )?;
484 let rows = stmt
485 .query_map([], |r| {
486 Ok(EntityNode {
487 id: r.get(0)?,
488 name: r.get(1)?,
489 namespace: r.get(2)?,
490 kind: r.get(3)?,
491 })
492 })?
493 .collect::<Result<Vec<_>, _>>()?;
494 Ok(rows)
495 }
496}
497
498pub fn list_relationships_by_namespace(
504 conn: &Connection,
505 namespace: Option<&str>,
506) -> Result<Vec<RelationshipRow>, AppError> {
507 if let Some(ns) = namespace {
508 let mut stmt = conn.prepare_cached(
509 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
510 FROM relationships r
511 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
512 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
513 ORDER BY r.id",
514 )?;
515 let rows = stmt
516 .query_map(params![ns], |r| {
517 Ok(RelationshipRow {
518 id: r.get(0)?,
519 namespace: r.get(1)?,
520 source_id: r.get(2)?,
521 target_id: r.get(3)?,
522 relation: r.get(4)?,
523 weight: r.get(5)?,
524 description: r.get(6)?,
525 })
526 })?
527 .collect::<Result<Vec<_>, _>>()?;
528 Ok(rows)
529 } else {
530 let mut stmt = conn.prepare_cached(
531 "SELECT id, namespace, source_id, target_id, relation, weight, description
532 FROM relationships ORDER BY id",
533 )?;
534 let rows = stmt
535 .query_map([], |r| {
536 Ok(RelationshipRow {
537 id: r.get(0)?,
538 namespace: r.get(1)?,
539 source_id: r.get(2)?,
540 target_id: r.get(3)?,
541 relation: r.get(4)?,
542 weight: r.get(5)?,
543 description: r.get(6)?,
544 })
545 })?
546 .collect::<Result<Vec<_>, _>>()?;
547 Ok(rows)
548 }
549}
550
551pub fn find_orphan_entity_ids(
557 conn: &Connection,
558 namespace: Option<&str>,
559) -> Result<Vec<i64>, AppError> {
560 if let Some(ns) = namespace {
561 let mut stmt = conn.prepare_cached(
562 "SELECT e.id FROM entities e
563 WHERE e.namespace = ?1
564 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
565 AND NOT EXISTS (
566 SELECT 1 FROM relationships r
567 WHERE r.source_id = e.id OR r.target_id = e.id
568 )",
569 )?;
570 let ids = stmt
571 .query_map(params![ns], |r| r.get::<_, i64>(0))?
572 .collect::<Result<Vec<_>, _>>()?;
573 Ok(ids)
574 } else {
575 let mut stmt = conn.prepare_cached(
576 "SELECT e.id FROM entities e
577 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
578 AND NOT EXISTS (
579 SELECT 1 FROM relationships r
580 WHERE r.source_id = e.id OR r.target_id = e.id
581 )",
582 )?;
583 let ids = stmt
584 .query_map([], |r| r.get::<_, i64>(0))?
585 .collect::<Result<Vec<_>, _>>()?;
586 Ok(ids)
587 }
588}
589
590pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
596 if entity_ids.is_empty() {
597 return Ok(0);
598 }
599 let mut removed = 0usize;
600 for id in entity_ids {
601 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
603 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
604 removed += affected;
605 }
606 Ok(removed)
607}
608
609pub fn count_relationships_by_relation(
618 conn: &Connection,
619 namespace: &str,
620 relation: &str,
621) -> Result<usize, AppError> {
622 let count: i64 = conn.query_row(
623 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
624 params![namespace, relation],
625 |r| r.get(0),
626 )?;
627 Ok(count as usize)
628}
629
630pub fn list_entity_names_by_relation(
639 conn: &Connection,
640 namespace: &str,
641 relation: &str,
642) -> Result<Vec<String>, AppError> {
643 let mut stmt = conn.prepare_cached(
644 "SELECT DISTINCT e.name FROM entities e
645 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
646 WHERE r.namespace = ?1 AND r.relation = ?2
647 ORDER BY e.name",
648 )?;
649 let names: Vec<String> = stmt
650 .query_map(params![namespace, relation], |row| row.get(0))?
651 .collect::<Result<Vec<_>, _>>()?;
652 Ok(names)
653}
654
655pub fn delete_relationships_by_relation(
666 conn: &Connection,
667 namespace: &str,
668 relation: &str,
669) -> Result<(usize, Vec<i64>), AppError> {
670 let mut stmt = conn.prepare_cached(
672 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
673 UNION
674 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
675 )?;
676 let entity_ids: Vec<i64> = stmt
677 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
678 .collect::<Result<Vec<_>, _>>()?;
679
680 let mut id_stmt =
682 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
683 let rel_ids: Vec<i64> = id_stmt
684 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
685 .collect::<Result<Vec<_>, _>>()?;
686
687 let mut total_deleted: usize = 0;
689 for chunk in rel_ids.chunks(1000) {
690 for &rel_id in chunk {
691 conn.execute(
692 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
693 params![rel_id],
694 )?;
695 let affected =
696 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
697 total_deleted += affected;
698 }
699 }
700
701 for &eid in &entity_ids {
703 recalculate_degree(conn, eid)?;
704 }
705
706 Ok((total_deleted, entity_ids))
707}
708
709pub fn knn_search(
722 conn: &Connection,
723 embedding: &[f32],
724 namespace: &str,
725 k: usize,
726) -> Result<Vec<(i64, f32)>, AppError> {
727 if embedding.len() != crate::constants::embedding_dim() {
728 return Err(AppError::Embedding(format!(
729 "knn_search embedding has {} dims, expected {}",
730 embedding.len(),
731 crate::constants::embedding_dim()
732 )));
733 }
734 let mut stmt = conn.prepare_cached(
735 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
736 )?;
737 let mut scored: Vec<(i64, f32)> = stmt
738 .query_map(params![namespace], |r| {
739 let id: i64 = r.get(0)?;
740 let bytes: Vec<u8> = r.get(1)?;
741 Ok((id, bytes))
742 })?
743 .filter_map(|row| {
744 row.ok().and_then(|(id, bytes)| {
745 let stored = crate::embedder::bytes_to_f32(&bytes);
746 if stored.len() != embedding.len() {
747 return None;
748 }
749 let score = crate::similarity::cosine_similarity(embedding, &stored);
750 Some((id, score))
751 })
752 })
753 .collect();
754 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
757 scored.truncate(k);
758 Ok(scored)
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::constants::embedding_dim;
765 use crate::entity_type::EntityType;
766 use crate::storage::connection::register_vec_extension;
767 use rusqlite::Connection;
768 use tempfile::TempDir;
769
770 type TestResult = Result<(), Box<dyn std::error::Error>>;
771
772 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
773 register_vec_extension();
774 let tmp = TempDir::new()?;
775 let db_path = tmp.path().join("test.db");
776 let mut conn = Connection::open(&db_path)?;
777 crate::migrations::runner().run(&mut conn)?;
778 Ok((tmp, conn))
779 }
780
781 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
782 conn.execute(
783 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
784 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
785 [],
786 )?;
787 Ok(conn.last_insert_rowid())
788 }
789
790 fn new_entity_helper(name: &str) -> NewEntity {
791 NewEntity {
792 name: name.to_string(),
793 entity_type: EntityType::Project,
794 description: None,
795 }
796 }
797
798 fn embedding_zero() -> Vec<f32> {
799 vec![0.0f32; embedding_dim()]
800 }
801
802 #[test]
807 fn test_upsert_entity_creates_new() -> TestResult {
808 let (_tmp, conn) = setup_db()?;
809 let e = new_entity_helper("projeto-alpha");
810 let id = upsert_entity(&conn, "global", &e)?;
811 assert!(id > 0);
812 Ok(())
813 }
814
815 #[test]
816 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
817 let (_tmp, conn) = setup_db()?;
818 let e = new_entity_helper("projeto-beta");
819 let id1 = upsert_entity(&conn, "global", &e)?;
820 let id2 = upsert_entity(&conn, "global", &e)?;
821 assert_eq!(id1, id2);
822 Ok(())
823 }
824
825 #[test]
826 fn test_upsert_entity_updates_description() -> TestResult {
827 let (_tmp, conn) = setup_db()?;
828 let e1 = new_entity_helper("projeto-gamma");
829 let id1 = upsert_entity(&conn, "global", &e1)?;
830
831 let e2 = NewEntity {
832 name: "projeto-gamma".to_string(),
833 entity_type: EntityType::Tool,
834 description: Some("nova desc".to_string()),
835 };
836 let id2 = upsert_entity(&conn, "global", &e2)?;
837 assert_eq!(id1, id2);
838
839 let desc: Option<String> = conn.query_row(
840 "SELECT description FROM entities WHERE id = ?1",
841 params![id1],
842 |r| r.get(0),
843 )?;
844 assert_eq!(desc.as_deref(), Some("nova desc"));
845 Ok(())
846 }
847
848 #[test]
849 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
850 let (_tmp, conn) = setup_db()?;
851 let e = new_entity_helper("compartilhada");
852 let id1 = upsert_entity(&conn, "ns1", &e)?;
853 let id2 = upsert_entity(&conn, "ns2", &e)?;
854 assert_ne!(id1, id2);
855 Ok(())
856 }
857
858 #[test]
865 fn test_upsert_entity_vec_empty_embedding_skips_row() -> TestResult {
866 let (_tmp, conn) = setup_db()?;
867 let e = new_entity_helper("vec-vazia");
868 let entity_id = upsert_entity(&conn, "global", &e)?;
869
870 upsert_entity_vec(
871 &conn,
872 entity_id,
873 "global",
874 EntityType::Project,
875 &[],
876 "vec-vazia",
877 )?;
878
879 let count: i64 = conn.query_row(
880 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
881 params![entity_id],
882 |r| r.get(0),
883 )?;
884 assert_eq!(count, 0, "empty embedding must not persist a row");
885 Ok(())
886 }
887
888 #[test]
890 #[serial_test::serial(env)]
891 fn test_upsert_entity_vec_empty_embedding_preserves_existing_row() -> TestResult {
892 let (_tmp, conn) = setup_db()?;
893 let e = new_entity_helper("vec-preservada");
894 let entity_id = upsert_entity(&conn, "global", &e)?;
895 let emb = embedding_zero();
896 upsert_entity_vec(
897 &conn,
898 entity_id,
899 "global",
900 EntityType::Project,
901 &emb,
902 "vec-preservada",
903 )?;
904
905 upsert_entity_vec(
906 &conn,
907 entity_id,
908 "global",
909 EntityType::Project,
910 &[],
911 "vec-preservada",
912 )?;
913
914 let count: i64 = conn.query_row(
915 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
916 params![entity_id],
917 |r| r.get(0),
918 )?;
919 assert_eq!(count, 1, "existing vector must survive an empty upsert");
920 Ok(())
921 }
922
923 #[test]
924 #[serial_test::serial(env)]
925 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
926 let (_tmp, conn) = setup_db()?;
927 let e = new_entity_helper("vec-nova");
928 let entity_id = upsert_entity(&conn, "global", &e)?;
929 let emb = embedding_zero();
930
931 let result = upsert_entity_vec(
932 &conn,
933 entity_id,
934 "global",
935 EntityType::Project,
936 &emb,
937 "vec-nova",
938 );
939 assert!(result.is_ok(), "first insertion must succeed");
940
941 let count: i64 = conn.query_row(
942 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
943 params![entity_id],
944 |r| r.get(0),
945 )?;
946 assert_eq!(count, 1, "must have exactly one row after insertion");
947 Ok(())
948 }
949
950 #[test]
951 #[serial_test::serial(env)]
952 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
953 let (_tmp, conn) = setup_db()?;
955 let e = new_entity_helper("vec-existente");
956 let entity_id = upsert_entity(&conn, "global", &e)?;
957 let emb = embedding_zero();
958
959 upsert_entity_vec(
960 &conn,
961 entity_id,
962 "global",
963 EntityType::Project,
964 &emb,
965 "vec-existente",
966 )?;
967
968 let result = upsert_entity_vec(
970 &conn,
971 entity_id,
972 "global",
973 EntityType::Tool,
974 &emb,
975 "vec-existente",
976 );
977 assert!(
978 result.is_ok(),
979 "second insertion (replace) must succeed: {result:?}"
980 );
981
982 let count: i64 = conn.query_row(
983 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
984 params![entity_id],
985 |r| r.get(0),
986 )?;
987 assert_eq!(count, 1, "must have exactly one row after replacement");
988 Ok(())
989 }
990
991 #[test]
992 #[serial_test::serial(env)]
993 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
994 let (_tmp, conn) = setup_db()?;
995 let emb = embedding_zero();
996
997 for i in 0..3i64 {
998 let nome = format!("ent-{i}");
999 let e = new_entity_helper(&nome);
1000 let entity_id = upsert_entity(&conn, "global", &e)?;
1001 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
1002 }
1003
1004 let count: i64 =
1005 conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
1006 assert_eq!(
1007 count, 3,
1008 "must have three distinct rows in entity_embeddings"
1009 );
1010 Ok(())
1011 }
1012
1013 #[test]
1018 fn test_find_entity_id_existing_returns_some() -> TestResult {
1019 let (_tmp, conn) = setup_db()?;
1020 let e = new_entity_helper("entidade-busca");
1021 let id_inserido = upsert_entity(&conn, "global", &e)?;
1022 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
1023 assert_eq!(id_encontrado, Some(id_inserido));
1024 Ok(())
1025 }
1026
1027 #[test]
1028 fn test_find_entity_id_missing_returns_none() -> TestResult {
1029 let (_tmp, conn) = setup_db()?;
1030 let id = find_entity_id(&conn, "global", "nao-existe")?;
1031 assert_eq!(id, None);
1032 Ok(())
1033 }
1034
1035 #[test]
1040 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
1041 let (_tmp, conn) = setup_db()?;
1042 let removed = delete_entities_by_ids(&conn, &[])?;
1043 assert_eq!(removed, 0);
1044 Ok(())
1045 }
1046
1047 #[test]
1048 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
1049 let (_tmp, conn) = setup_db()?;
1050 let e = new_entity_helper("to-delete");
1051 let entity_id = upsert_entity(&conn, "global", &e)?;
1052
1053 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
1054 assert_eq!(removed, 1);
1055
1056 let id = find_entity_id(&conn, "global", "to-delete")?;
1057 assert_eq!(id, None, "entity must have been removed");
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
1063 let (_tmp, conn) = setup_db()?;
1064 let removed = delete_entities_by_ids(&conn, &[9999])?;
1065 assert_eq!(removed, 0);
1066 Ok(())
1067 }
1068
1069 #[test]
1070 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
1071 let (_tmp, conn) = setup_db()?;
1072 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
1073 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
1074 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
1075
1076 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
1077 assert_eq!(removed, 2);
1078
1079 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
1080 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
1081 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
1082 let _ = id3;
1083 Ok(())
1084 }
1085
1086 #[test]
1087 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
1088 let (_tmp, conn) = setup_db()?;
1089 let e = new_entity_helper("del-com-vec");
1090 let entity_id = upsert_entity(&conn, "global", &e)?;
1091 let emb = embedding_zero();
1092 upsert_entity_vec(
1093 &conn,
1094 entity_id,
1095 "global",
1096 EntityType::Project,
1097 &emb,
1098 "del-com-vec",
1099 )?;
1100
1101 let count_antes: i64 = conn.query_row(
1102 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1103 params![entity_id],
1104 |r| r.get(0),
1105 )?;
1106 assert_eq!(count_antes, 1);
1107
1108 delete_entities_by_ids(&conn, &[entity_id])?;
1109
1110 let count_depois: i64 = conn.query_row(
1111 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1112 params![entity_id],
1113 |r| r.get(0),
1114 )?;
1115 assert_eq!(
1116 count_depois, 0,
1117 "entity_embeddings deve ser limpo junto com entities"
1118 );
1119 Ok(())
1120 }
1121
1122 #[test]
1127 fn test_upsert_relationship_creates_new() -> TestResult {
1128 let (_tmp, conn) = setup_db()?;
1129 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1130 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1131
1132 let rel = NewRelationship {
1133 source: "rel-a".to_string(),
1134 target: "rel-b".to_string(),
1135 relation: "uses".to_string(),
1136 strength: 0.8,
1137 description: None,
1138 };
1139 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1140 assert!(rel_id > 0);
1141 Ok(())
1142 }
1143
1144 #[test]
1145 fn test_upsert_relationship_idempotent() -> TestResult {
1146 let (_tmp, conn) = setup_db()?;
1147 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1148 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1149
1150 let rel = NewRelationship {
1151 source: "idem-a".to_string(),
1152 target: "idem-b".to_string(),
1153 relation: "uses".to_string(),
1154 strength: 0.5,
1155 description: None,
1156 };
1157 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1158 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1159 assert_eq!(id1, id2);
1160 Ok(())
1161 }
1162
1163 #[test]
1164 fn test_find_relationship_existing() -> TestResult {
1165 let (_tmp, conn) = setup_db()?;
1166 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1167 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1168
1169 let rel = NewRelationship {
1170 source: "fr-a".to_string(),
1171 target: "fr-b".to_string(),
1172 relation: "depends_on".to_string(),
1173 strength: 0.7,
1174 description: None,
1175 };
1176 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1177
1178 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1179 let row = encontrada.ok_or("relationship should exist")?;
1180 assert_eq!(row.source_id, id_a);
1181 assert_eq!(row.target_id, id_b);
1182 assert!((row.weight - 0.7).abs() < 1e-9);
1183 Ok(())
1184 }
1185
1186 #[test]
1187 fn test_find_relationship_missing_returns_none() -> TestResult {
1188 let (_tmp, conn) = setup_db()?;
1189 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1190 assert!(resultado.is_none());
1191 Ok(())
1192 }
1193
1194 #[test]
1199 fn test_link_memory_entity_idempotent() -> TestResult {
1200 let (_tmp, conn) = setup_db()?;
1201 let memory_id = insert_memory(&conn)?;
1202 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1203
1204 link_memory_entity(&conn, memory_id, entity_id)?;
1205 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1206 assert!(
1207 resultado.is_ok(),
1208 "INSERT OR IGNORE must not fail on duplicate"
1209 );
1210 Ok(())
1211 }
1212
1213 #[test]
1214 fn test_link_memory_relationship_idempotent() -> TestResult {
1215 let (_tmp, conn) = setup_db()?;
1216 let memory_id = insert_memory(&conn)?;
1217 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1218 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1219
1220 let rel = NewRelationship {
1221 source: "mr-a".to_string(),
1222 target: "mr-b".to_string(),
1223 relation: "uses".to_string(),
1224 strength: 0.5,
1225 description: None,
1226 };
1227 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1228
1229 link_memory_relationship(&conn, memory_id, rel_id)?;
1230 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1231 assert!(
1232 resultado.is_ok(),
1233 "INSERT OR IGNORE must not fail on duplicate"
1234 );
1235 Ok(())
1236 }
1237
1238 #[test]
1243 fn test_increment_degree_increases_counter() -> TestResult {
1244 let (_tmp, conn) = setup_db()?;
1245 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1246
1247 increment_degree(&conn, entity_id)?;
1248 increment_degree(&conn, entity_id)?;
1249
1250 let degree: i64 = conn.query_row(
1251 "SELECT degree FROM entities WHERE id = ?1",
1252 params![entity_id],
1253 |r| r.get(0),
1254 )?;
1255 assert_eq!(degree, 2);
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1261 let (_tmp, conn) = setup_db()?;
1262 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1263 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1264 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1265
1266 let rel1 = NewRelationship {
1267 source: "rc-a".to_string(),
1268 target: "rc-b".to_string(),
1269 relation: "uses".to_string(),
1270 strength: 0.5,
1271 description: None,
1272 };
1273 let rel2 = NewRelationship {
1274 source: "rc-c".to_string(),
1275 target: "rc-a".to_string(),
1276 relation: "depends_on".to_string(),
1277 strength: 0.5,
1278 description: None,
1279 };
1280 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1281 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1282
1283 recalculate_degree(&conn, id_a)?;
1284
1285 let degree: i64 = conn.query_row(
1286 "SELECT degree FROM entities WHERE id = ?1",
1287 params![id_a],
1288 |r| r.get(0),
1289 )?;
1290 assert_eq!(
1291 degree, 2,
1292 "rc-a appears in two relationships (source+target)"
1293 );
1294 Ok(())
1295 }
1296
1297 #[test]
1302 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1303 let (_tmp, conn) = setup_db()?;
1304 let memory_id = insert_memory(&conn)?;
1305 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1306 link_memory_entity(&conn, memory_id, entity_id)?;
1307
1308 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1309 assert!(!orfas.contains(&entity_id));
1310 Ok(())
1311 }
1312
1313 #[test]
1314 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1315 let (_tmp, conn) = setup_db()?;
1316 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1317
1318 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1319 assert!(orfas.contains(&entity_id));
1320 Ok(())
1321 }
1322
1323 #[test]
1324 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1325 let (_tmp, conn) = setup_db()?;
1326 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1327 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1328
1329 let orfas = find_orphan_entity_ids(&conn, None)?;
1330 assert!(orfas.contains(&id1));
1331 assert!(orfas.contains(&id2));
1332 Ok(())
1333 }
1334
1335 #[test]
1340 fn test_list_entities_with_namespace() -> TestResult {
1341 let (_tmp, conn) = setup_db()?;
1342 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1343 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1344 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1345
1346 let lista = list_entities(&conn, Some("le-ns"))?;
1347 assert_eq!(lista.len(), 2);
1348 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1349 Ok(())
1350 }
1351
1352 #[test]
1353 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1354 let (_tmp, conn) = setup_db()?;
1355 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1356 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1357
1358 let lista = list_entities(&conn, None)?;
1359 assert!(lista.len() >= 2);
1360 Ok(())
1361 }
1362
1363 #[test]
1364 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1365 let (_tmp, conn) = setup_db()?;
1366 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1367 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1368
1369 let rel = NewRelationship {
1370 source: "lr-a".to_string(),
1371 target: "lr-b".to_string(),
1372 relation: "uses".to_string(),
1373 strength: 0.5,
1374 description: None,
1375 };
1376 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1377
1378 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1379 assert!(!lista.is_empty());
1380 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1381 Ok(())
1382 }
1383
1384 #[test]
1389 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1390 let (_tmp, conn) = setup_db()?;
1391 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1392 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1393
1394 let rel = NewRelationship {
1395 source: "dr-a".to_string(),
1396 target: "dr-b".to_string(),
1397 relation: "uses".to_string(),
1398 strength: 0.5,
1399 description: None,
1400 };
1401 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1402
1403 delete_relationship_by_id(&conn, rel_id)?;
1404
1405 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1406 assert!(encontrada.is_none(), "relationship must have been removed");
1407 Ok(())
1408 }
1409
1410 #[test]
1411 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1412 let (_tmp, conn) = setup_db()?;
1413 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1414 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1415
1416 let (rel_id, created) =
1417 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1418 assert!(rel_id > 0);
1419 assert!(created);
1420 Ok(())
1421 }
1422
1423 #[test]
1424 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1425 let (_tmp, conn) = setup_db()?;
1426 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1427 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1428
1429 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1430 let (_, created) =
1431 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1432 assert!(
1433 !created,
1434 "second call must return the existing relationship"
1435 );
1436 Ok(())
1437 }
1438
1439 #[test]
1444 fn accepts_type_field_as_alias() -> TestResult {
1445 let json = r#"{"name": "X", "type": "concept"}"#;
1446 let ent: NewEntity = serde_json::from_str(json)?;
1447 assert_eq!(ent.entity_type, EntityType::Concept);
1448 Ok(())
1449 }
1450
1451 #[test]
1452 fn accepts_canonical_entity_type_field() -> TestResult {
1453 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1454 let ent: NewEntity = serde_json::from_str(json)?;
1455 assert_eq!(ent.entity_type, EntityType::Concept);
1456 Ok(())
1457 }
1458
1459 #[test]
1460 fn both_fields_present_yields_duplicate_error() {
1461 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1463 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1464 assert!(
1465 resultado.is_err(),
1466 "both fields in the same JSON are a duplicate"
1467 );
1468 }
1469
1470 #[test]
1471 fn validate_entity_name_accepts_valid() {
1472 assert!(validate_entity_name("rust-lang").is_ok());
1473 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1474 assert!(validate_entity_name("ab").is_ok());
1475 }
1476
1477 #[test]
1478 fn validate_entity_name_rejects_short() {
1479 assert!(validate_entity_name("a").is_err());
1480 assert!(validate_entity_name("").is_err());
1481 }
1482
1483 #[test]
1484 fn validate_entity_name_rejects_newlines() {
1485 assert!(validate_entity_name("foo\nbar").is_err());
1486 assert!(validate_entity_name("foo\rbar").is_err());
1487 }
1488
1489 #[test]
1490 fn validate_entity_name_rejects_short_allcaps() {
1491 assert!(validate_entity_name("RAM").is_err());
1492 assert!(validate_entity_name("NAO").is_err());
1493 assert!(validate_entity_name("OK").is_err());
1494 }
1495
1496 #[test]
1497 fn validate_entity_name_accepts_long_allcaps() {
1498 assert!(validate_entity_name("SQLITE").is_ok());
1499 assert!(validate_entity_name("GRAPHRAG").is_ok());
1500 }
1501
1502 #[test]
1503 fn validate_entity_name_accepts_mixed_case() {
1504 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1507
1508 #[test]
1510 fn test_unlink_memory_entity_removes_single_binding() -> TestResult {
1511 let (_tmp, conn) = setup_db()?;
1512 let memory_id = insert_memory(&conn)?;
1513 let e1 = upsert_entity(&conn, "global", &new_entity_helper("entidade-um"))?;
1514 let e2 = upsert_entity(&conn, "global", &new_entity_helper("entidade-dois"))?;
1515 link_memory_entity(&conn, memory_id, e1)?;
1516 link_memory_entity(&conn, memory_id, e2)?;
1517
1518 let removed = unlink_memory_entity(&conn, memory_id, e1)?;
1519 assert_eq!(removed, 1);
1520
1521 let remaining: i64 = conn.query_row(
1523 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1524 params![memory_id],
1525 |r| r.get(0),
1526 )?;
1527 assert_eq!(remaining, 1);
1528
1529 assert_eq!(unlink_memory_entity(&conn, memory_id, e1)?, 0);
1531 Ok(())
1532 }
1533
1534 #[test]
1536 fn test_clear_memory_graph_bindings_clears_all() -> TestResult {
1537 let (_tmp, conn) = setup_db()?;
1538 let memory_id = insert_memory(&conn)?;
1539 let e1 = upsert_entity(&conn, "global", &new_entity_helper("alpha-node"))?;
1540 let e2 = upsert_entity(&conn, "global", &new_entity_helper("beta-node"))?;
1541 link_memory_entity(&conn, memory_id, e1)?;
1542 link_memory_entity(&conn, memory_id, e2)?;
1543 let rel = NewRelationship {
1544 source: "alpha-node".to_string(),
1545 target: "beta-node".to_string(),
1546 relation: "related".to_string(),
1547 strength: 0.5,
1548 description: None,
1549 };
1550 let rel_id = upsert_relationship(&conn, "global", e1, e2, &rel)?;
1551 link_memory_relationship(&conn, memory_id, rel_id)?;
1552
1553 let (e_removed, r_removed) = clear_memory_graph_bindings(&conn, memory_id)?;
1554 assert_eq!(e_removed, 2);
1555 assert_eq!(r_removed, 1);
1556
1557 let ent_left: i64 = conn.query_row(
1558 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1559 params![memory_id],
1560 |r| r.get(0),
1561 )?;
1562 let rel_left: i64 = conn.query_row(
1563 "SELECT COUNT(*) FROM memory_relationships WHERE memory_id = ?1",
1564 params![memory_id],
1565 |r| r.get(0),
1566 )?;
1567 assert_eq!(ent_left, 0);
1568 assert_eq!(rel_left, 0);
1569 Ok(())
1570 }
1571}