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 unlink_memory_entity(
238 conn: &Connection,
239 memory_id: i64,
240 entity_id: i64,
241) -> Result<u64, AppError> {
242 let affected = conn.execute(
243 "DELETE FROM memory_entities WHERE memory_id = ?1 AND entity_id = ?2",
244 params![memory_id, entity_id],
245 )?;
246 Ok(affected as u64)
247}
248
249pub fn clear_memory_graph_bindings(
259 conn: &Connection,
260 memory_id: i64,
261) -> Result<(u64, u64), AppError> {
262 let entities_removed = conn.execute(
263 "DELETE FROM memory_entities WHERE memory_id = ?1",
264 params![memory_id],
265 )? as u64;
266 let rels_removed = conn.execute(
267 "DELETE FROM memory_relationships WHERE memory_id = ?1",
268 params![memory_id],
269 )? as u64;
270 Ok((entities_removed, rels_removed))
271}
272
273pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
279 conn.execute(
280 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
281 params![entity_id],
282 )?;
283 Ok(())
284}
285
286pub fn find_entity_id(
292 conn: &Connection,
293 namespace: &str,
294 name: &str,
295) -> Result<Option<i64>, AppError> {
296 let name = normalize_entity_name(name);
302 let mut stmt =
303 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
304 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
305 Ok(id) => Ok(Some(id)),
306 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
307 Err(e) => Err(AppError::Database(e)),
308 }
309}
310
311#[derive(Debug, Serialize)]
313pub struct RelationshipRow {
314 pub id: i64,
315 pub namespace: String,
316 pub source_id: i64,
317 pub target_id: i64,
318 pub relation: String,
319 pub weight: f64,
320 pub description: Option<String>,
321}
322
323pub fn find_relationship(
329 conn: &Connection,
330 source_id: i64,
331 target_id: i64,
332 relation: &str,
333) -> Result<Option<RelationshipRow>, AppError> {
334 let mut stmt = conn.prepare_cached(
335 "SELECT id, namespace, source_id, target_id, relation, weight, description
336 FROM relationships
337 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
338 )?;
339 match stmt.query_row(params![source_id, target_id, relation], |r| {
340 Ok(RelationshipRow {
341 id: r.get(0)?,
342 namespace: r.get(1)?,
343 source_id: r.get(2)?,
344 target_id: r.get(3)?,
345 relation: r.get(4)?,
346 weight: r.get(5)?,
347 description: r.get(6)?,
348 })
349 }) {
350 Ok(row) => Ok(Some(row)),
351 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
352 Err(e) => Err(AppError::Database(e)),
353 }
354}
355
356pub fn create_or_fetch_relationship(
364 conn: &Connection,
365 namespace: &str,
366 source_id: i64,
367 target_id: i64,
368 relation: &str,
369 weight: f64,
370 description: Option<&str>,
371) -> Result<(i64, bool), AppError> {
372 let existing = find_relationship(conn, source_id, target_id, relation)?;
374 if let Some(row) = existing {
375 if (row.weight - weight).abs() > f64::EPSILON {
376 conn.execute(
377 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
378 params![weight, row.id],
379 )?;
380 }
381 return Ok((row.id, false));
382 }
383 conn.execute(
384 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
385 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
386 params![
387 namespace,
388 source_id,
389 target_id,
390 relation,
391 weight,
392 description
393 ],
394 )?;
395 let id: i64 = conn.query_row(
396 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
397 params![source_id, target_id, relation],
398 |r| r.get(0),
399 )?;
400 Ok((id, true))
401}
402
403pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
409 conn.execute(
410 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
411 params![relationship_id],
412 )?;
413 conn.execute(
414 "DELETE FROM relationships WHERE id = ?1",
415 params![relationship_id],
416 )?;
417 Ok(())
418}
419
420pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
426 conn.execute(
427 "UPDATE entities
428 SET degree = (SELECT COUNT(*) FROM relationships
429 WHERE source_id = entities.id OR target_id = entities.id)
430 WHERE id = ?1",
431 params![entity_id],
432 )?;
433 Ok(())
434}
435
436#[derive(Debug, Serialize, Clone)]
438pub struct EntityNode {
439 pub id: i64,
440 pub name: String,
441 pub namespace: String,
442 pub kind: String,
443}
444
445pub fn list_entities(
451 conn: &Connection,
452 namespace: Option<&str>,
453) -> Result<Vec<EntityNode>, AppError> {
454 if let Some(ns) = namespace {
455 let mut stmt = conn.prepare_cached(
456 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
457 )?;
458 let rows = stmt
459 .query_map(params![ns], |r| {
460 Ok(EntityNode {
461 id: r.get(0)?,
462 name: r.get(1)?,
463 namespace: r.get(2)?,
464 kind: r.get(3)?,
465 })
466 })?
467 .collect::<Result<Vec<_>, _>>()?;
468 Ok(rows)
469 } else {
470 let mut stmt = conn.prepare_cached(
471 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
472 )?;
473 let rows = stmt
474 .query_map([], |r| {
475 Ok(EntityNode {
476 id: r.get(0)?,
477 name: r.get(1)?,
478 namespace: r.get(2)?,
479 kind: r.get(3)?,
480 })
481 })?
482 .collect::<Result<Vec<_>, _>>()?;
483 Ok(rows)
484 }
485}
486
487pub fn list_relationships_by_namespace(
493 conn: &Connection,
494 namespace: Option<&str>,
495) -> Result<Vec<RelationshipRow>, AppError> {
496 if let Some(ns) = namespace {
497 let mut stmt = conn.prepare_cached(
498 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
499 FROM relationships r
500 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
501 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
502 ORDER BY r.id",
503 )?;
504 let rows = stmt
505 .query_map(params![ns], |r| {
506 Ok(RelationshipRow {
507 id: r.get(0)?,
508 namespace: r.get(1)?,
509 source_id: r.get(2)?,
510 target_id: r.get(3)?,
511 relation: r.get(4)?,
512 weight: r.get(5)?,
513 description: r.get(6)?,
514 })
515 })?
516 .collect::<Result<Vec<_>, _>>()?;
517 Ok(rows)
518 } else {
519 let mut stmt = conn.prepare_cached(
520 "SELECT id, namespace, source_id, target_id, relation, weight, description
521 FROM relationships ORDER BY id",
522 )?;
523 let rows = stmt
524 .query_map([], |r| {
525 Ok(RelationshipRow {
526 id: r.get(0)?,
527 namespace: r.get(1)?,
528 source_id: r.get(2)?,
529 target_id: r.get(3)?,
530 relation: r.get(4)?,
531 weight: r.get(5)?,
532 description: r.get(6)?,
533 })
534 })?
535 .collect::<Result<Vec<_>, _>>()?;
536 Ok(rows)
537 }
538}
539
540pub fn find_orphan_entity_ids(
546 conn: &Connection,
547 namespace: Option<&str>,
548) -> Result<Vec<i64>, AppError> {
549 if let Some(ns) = namespace {
550 let mut stmt = conn.prepare_cached(
551 "SELECT e.id FROM entities e
552 WHERE e.namespace = ?1
553 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
554 AND NOT EXISTS (
555 SELECT 1 FROM relationships r
556 WHERE r.source_id = e.id OR r.target_id = e.id
557 )",
558 )?;
559 let ids = stmt
560 .query_map(params![ns], |r| r.get::<_, i64>(0))?
561 .collect::<Result<Vec<_>, _>>()?;
562 Ok(ids)
563 } else {
564 let mut stmt = conn.prepare_cached(
565 "SELECT e.id FROM entities e
566 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
567 AND NOT EXISTS (
568 SELECT 1 FROM relationships r
569 WHERE r.source_id = e.id OR r.target_id = e.id
570 )",
571 )?;
572 let ids = stmt
573 .query_map([], |r| r.get::<_, i64>(0))?
574 .collect::<Result<Vec<_>, _>>()?;
575 Ok(ids)
576 }
577}
578
579pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
585 if entity_ids.is_empty() {
586 return Ok(0);
587 }
588 let mut removed = 0usize;
589 for id in entity_ids {
590 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
592 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
593 removed += affected;
594 }
595 Ok(removed)
596}
597
598pub fn count_relationships_by_relation(
607 conn: &Connection,
608 namespace: &str,
609 relation: &str,
610) -> Result<usize, AppError> {
611 let count: i64 = conn.query_row(
612 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
613 params![namespace, relation],
614 |r| r.get(0),
615 )?;
616 Ok(count as usize)
617}
618
619pub fn list_entity_names_by_relation(
628 conn: &Connection,
629 namespace: &str,
630 relation: &str,
631) -> Result<Vec<String>, AppError> {
632 let mut stmt = conn.prepare_cached(
633 "SELECT DISTINCT e.name FROM entities e
634 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
635 WHERE r.namespace = ?1 AND r.relation = ?2
636 ORDER BY e.name",
637 )?;
638 let names: Vec<String> = stmt
639 .query_map(params![namespace, relation], |row| row.get(0))?
640 .collect::<Result<Vec<_>, _>>()?;
641 Ok(names)
642}
643
644pub fn delete_relationships_by_relation(
655 conn: &Connection,
656 namespace: &str,
657 relation: &str,
658) -> Result<(usize, Vec<i64>), AppError> {
659 let mut stmt = conn.prepare_cached(
661 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
662 UNION
663 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
664 )?;
665 let entity_ids: Vec<i64> = stmt
666 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
667 .collect::<Result<Vec<_>, _>>()?;
668
669 let mut id_stmt =
671 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
672 let rel_ids: Vec<i64> = id_stmt
673 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
674 .collect::<Result<Vec<_>, _>>()?;
675
676 let mut total_deleted: usize = 0;
678 for chunk in rel_ids.chunks(1000) {
679 for &rel_id in chunk {
680 conn.execute(
681 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
682 params![rel_id],
683 )?;
684 let affected =
685 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
686 total_deleted += affected;
687 }
688 }
689
690 for &eid in &entity_ids {
692 recalculate_degree(conn, eid)?;
693 }
694
695 Ok((total_deleted, entity_ids))
696}
697
698pub fn knn_search(
711 conn: &Connection,
712 embedding: &[f32],
713 namespace: &str,
714 k: usize,
715) -> Result<Vec<(i64, f32)>, AppError> {
716 if embedding.len() != crate::constants::embedding_dim() {
717 return Err(AppError::Embedding(format!(
718 "knn_search embedding has {} dims, expected {}",
719 embedding.len(),
720 crate::constants::embedding_dim()
721 )));
722 }
723 let mut stmt = conn.prepare_cached(
724 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
725 )?;
726 let mut scored: Vec<(i64, f32)> = stmt
727 .query_map(params![namespace], |r| {
728 let id: i64 = r.get(0)?;
729 let bytes: Vec<u8> = r.get(1)?;
730 Ok((id, bytes))
731 })?
732 .filter_map(|row| {
733 row.ok().and_then(|(id, bytes)| {
734 let stored = crate::embedder::bytes_to_f32(&bytes);
735 if stored.len() != embedding.len() {
736 return None;
737 }
738 let score = crate::similarity::cosine_similarity(embedding, &stored);
739 Some((id, score))
740 })
741 })
742 .collect();
743 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
746 scored.truncate(k);
747 Ok(scored)
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::constants::embedding_dim;
754 use crate::entity_type::EntityType;
755 use crate::storage::connection::register_vec_extension;
756 use rusqlite::Connection;
757 use tempfile::TempDir;
758
759 type TestResult = Result<(), Box<dyn std::error::Error>>;
760
761 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
762 register_vec_extension();
763 let tmp = TempDir::new()?;
764 let db_path = tmp.path().join("test.db");
765 let mut conn = Connection::open(&db_path)?;
766 crate::migrations::runner().run(&mut conn)?;
767 Ok((tmp, conn))
768 }
769
770 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
771 conn.execute(
772 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
773 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
774 [],
775 )?;
776 Ok(conn.last_insert_rowid())
777 }
778
779 fn new_entity_helper(name: &str) -> NewEntity {
780 NewEntity {
781 name: name.to_string(),
782 entity_type: EntityType::Project,
783 description: None,
784 }
785 }
786
787 fn embedding_zero() -> Vec<f32> {
788 vec![0.0f32; embedding_dim()]
789 }
790
791 #[test]
796 fn test_upsert_entity_creates_new() -> TestResult {
797 let (_tmp, conn) = setup_db()?;
798 let e = new_entity_helper("projeto-alpha");
799 let id = upsert_entity(&conn, "global", &e)?;
800 assert!(id > 0);
801 Ok(())
802 }
803
804 #[test]
805 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
806 let (_tmp, conn) = setup_db()?;
807 let e = new_entity_helper("projeto-beta");
808 let id1 = upsert_entity(&conn, "global", &e)?;
809 let id2 = upsert_entity(&conn, "global", &e)?;
810 assert_eq!(id1, id2);
811 Ok(())
812 }
813
814 #[test]
815 fn test_upsert_entity_updates_description() -> TestResult {
816 let (_tmp, conn) = setup_db()?;
817 let e1 = new_entity_helper("projeto-gamma");
818 let id1 = upsert_entity(&conn, "global", &e1)?;
819
820 let e2 = NewEntity {
821 name: "projeto-gamma".to_string(),
822 entity_type: EntityType::Tool,
823 description: Some("nova desc".to_string()),
824 };
825 let id2 = upsert_entity(&conn, "global", &e2)?;
826 assert_eq!(id1, id2);
827
828 let desc: Option<String> = conn.query_row(
829 "SELECT description FROM entities WHERE id = ?1",
830 params![id1],
831 |r| r.get(0),
832 )?;
833 assert_eq!(desc.as_deref(), Some("nova desc"));
834 Ok(())
835 }
836
837 #[test]
838 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
839 let (_tmp, conn) = setup_db()?;
840 let e = new_entity_helper("compartilhada");
841 let id1 = upsert_entity(&conn, "ns1", &e)?;
842 let id2 = upsert_entity(&conn, "ns2", &e)?;
843 assert_ne!(id1, id2);
844 Ok(())
845 }
846
847 #[test]
852 #[serial_test::serial(env)]
853 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
854 let (_tmp, conn) = setup_db()?;
855 let e = new_entity_helper("vec-nova");
856 let entity_id = upsert_entity(&conn, "global", &e)?;
857 let emb = embedding_zero();
858
859 let result = upsert_entity_vec(
860 &conn,
861 entity_id,
862 "global",
863 EntityType::Project,
864 &emb,
865 "vec-nova",
866 );
867 assert!(result.is_ok(), "first insertion must succeed");
868
869 let count: i64 = conn.query_row(
870 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
871 params![entity_id],
872 |r| r.get(0),
873 )?;
874 assert_eq!(count, 1, "must have exactly one row after insertion");
875 Ok(())
876 }
877
878 #[test]
879 #[serial_test::serial(env)]
880 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
881 let (_tmp, conn) = setup_db()?;
883 let e = new_entity_helper("vec-existente");
884 let entity_id = upsert_entity(&conn, "global", &e)?;
885 let emb = embedding_zero();
886
887 upsert_entity_vec(
888 &conn,
889 entity_id,
890 "global",
891 EntityType::Project,
892 &emb,
893 "vec-existente",
894 )?;
895
896 let result = upsert_entity_vec(
898 &conn,
899 entity_id,
900 "global",
901 EntityType::Tool,
902 &emb,
903 "vec-existente",
904 );
905 assert!(
906 result.is_ok(),
907 "second insertion (replace) must succeed: {result:?}"
908 );
909
910 let count: i64 = conn.query_row(
911 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
912 params![entity_id],
913 |r| r.get(0),
914 )?;
915 assert_eq!(count, 1, "must have exactly one row after replacement");
916 Ok(())
917 }
918
919 #[test]
920 #[serial_test::serial(env)]
921 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
922 let (_tmp, conn) = setup_db()?;
923 let emb = embedding_zero();
924
925 for i in 0..3i64 {
926 let nome = format!("ent-{i}");
927 let e = new_entity_helper(&nome);
928 let entity_id = upsert_entity(&conn, "global", &e)?;
929 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
930 }
931
932 let count: i64 =
933 conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
934 assert_eq!(
935 count, 3,
936 "must have three distinct rows in entity_embeddings"
937 );
938 Ok(())
939 }
940
941 #[test]
946 fn test_find_entity_id_existing_returns_some() -> TestResult {
947 let (_tmp, conn) = setup_db()?;
948 let e = new_entity_helper("entidade-busca");
949 let id_inserido = upsert_entity(&conn, "global", &e)?;
950 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
951 assert_eq!(id_encontrado, Some(id_inserido));
952 Ok(())
953 }
954
955 #[test]
956 fn test_find_entity_id_missing_returns_none() -> TestResult {
957 let (_tmp, conn) = setup_db()?;
958 let id = find_entity_id(&conn, "global", "nao-existe")?;
959 assert_eq!(id, None);
960 Ok(())
961 }
962
963 #[test]
968 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
969 let (_tmp, conn) = setup_db()?;
970 let removed = delete_entities_by_ids(&conn, &[])?;
971 assert_eq!(removed, 0);
972 Ok(())
973 }
974
975 #[test]
976 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
977 let (_tmp, conn) = setup_db()?;
978 let e = new_entity_helper("to-delete");
979 let entity_id = upsert_entity(&conn, "global", &e)?;
980
981 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
982 assert_eq!(removed, 1);
983
984 let id = find_entity_id(&conn, "global", "to-delete")?;
985 assert_eq!(id, None, "entity must have been removed");
986 Ok(())
987 }
988
989 #[test]
990 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
991 let (_tmp, conn) = setup_db()?;
992 let removed = delete_entities_by_ids(&conn, &[9999])?;
993 assert_eq!(removed, 0);
994 Ok(())
995 }
996
997 #[test]
998 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
999 let (_tmp, conn) = setup_db()?;
1000 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
1001 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
1002 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
1003
1004 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
1005 assert_eq!(removed, 2);
1006
1007 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
1008 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
1009 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
1010 let _ = id3;
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
1016 let (_tmp, conn) = setup_db()?;
1017 let e = new_entity_helper("del-com-vec");
1018 let entity_id = upsert_entity(&conn, "global", &e)?;
1019 let emb = embedding_zero();
1020 upsert_entity_vec(
1021 &conn,
1022 entity_id,
1023 "global",
1024 EntityType::Project,
1025 &emb,
1026 "del-com-vec",
1027 )?;
1028
1029 let count_antes: i64 = conn.query_row(
1030 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1031 params![entity_id],
1032 |r| r.get(0),
1033 )?;
1034 assert_eq!(count_antes, 1);
1035
1036 delete_entities_by_ids(&conn, &[entity_id])?;
1037
1038 let count_depois: i64 = conn.query_row(
1039 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1040 params![entity_id],
1041 |r| r.get(0),
1042 )?;
1043 assert_eq!(
1044 count_depois, 0,
1045 "entity_embeddings deve ser limpo junto com entities"
1046 );
1047 Ok(())
1048 }
1049
1050 #[test]
1055 fn test_upsert_relationship_creates_new() -> TestResult {
1056 let (_tmp, conn) = setup_db()?;
1057 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1058 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1059
1060 let rel = NewRelationship {
1061 source: "rel-a".to_string(),
1062 target: "rel-b".to_string(),
1063 relation: "uses".to_string(),
1064 strength: 0.8,
1065 description: None,
1066 };
1067 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1068 assert!(rel_id > 0);
1069 Ok(())
1070 }
1071
1072 #[test]
1073 fn test_upsert_relationship_idempotent() -> TestResult {
1074 let (_tmp, conn) = setup_db()?;
1075 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1076 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1077
1078 let rel = NewRelationship {
1079 source: "idem-a".to_string(),
1080 target: "idem-b".to_string(),
1081 relation: "uses".to_string(),
1082 strength: 0.5,
1083 description: None,
1084 };
1085 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1086 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1087 assert_eq!(id1, id2);
1088 Ok(())
1089 }
1090
1091 #[test]
1092 fn test_find_relationship_existing() -> TestResult {
1093 let (_tmp, conn) = setup_db()?;
1094 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1095 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1096
1097 let rel = NewRelationship {
1098 source: "fr-a".to_string(),
1099 target: "fr-b".to_string(),
1100 relation: "depends_on".to_string(),
1101 strength: 0.7,
1102 description: None,
1103 };
1104 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1105
1106 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1107 let row = encontrada.ok_or("relationship should exist")?;
1108 assert_eq!(row.source_id, id_a);
1109 assert_eq!(row.target_id, id_b);
1110 assert!((row.weight - 0.7).abs() < 1e-9);
1111 Ok(())
1112 }
1113
1114 #[test]
1115 fn test_find_relationship_missing_returns_none() -> TestResult {
1116 let (_tmp, conn) = setup_db()?;
1117 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1118 assert!(resultado.is_none());
1119 Ok(())
1120 }
1121
1122 #[test]
1127 fn test_link_memory_entity_idempotent() -> TestResult {
1128 let (_tmp, conn) = setup_db()?;
1129 let memory_id = insert_memory(&conn)?;
1130 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1131
1132 link_memory_entity(&conn, memory_id, entity_id)?;
1133 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1134 assert!(
1135 resultado.is_ok(),
1136 "INSERT OR IGNORE must not fail on duplicate"
1137 );
1138 Ok(())
1139 }
1140
1141 #[test]
1142 fn test_link_memory_relationship_idempotent() -> TestResult {
1143 let (_tmp, conn) = setup_db()?;
1144 let memory_id = insert_memory(&conn)?;
1145 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1146 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1147
1148 let rel = NewRelationship {
1149 source: "mr-a".to_string(),
1150 target: "mr-b".to_string(),
1151 relation: "uses".to_string(),
1152 strength: 0.5,
1153 description: None,
1154 };
1155 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1156
1157 link_memory_relationship(&conn, memory_id, rel_id)?;
1158 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1159 assert!(
1160 resultado.is_ok(),
1161 "INSERT OR IGNORE must not fail on duplicate"
1162 );
1163 Ok(())
1164 }
1165
1166 #[test]
1171 fn test_increment_degree_increases_counter() -> TestResult {
1172 let (_tmp, conn) = setup_db()?;
1173 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1174
1175 increment_degree(&conn, entity_id)?;
1176 increment_degree(&conn, entity_id)?;
1177
1178 let degree: i64 = conn.query_row(
1179 "SELECT degree FROM entities WHERE id = ?1",
1180 params![entity_id],
1181 |r| r.get(0),
1182 )?;
1183 assert_eq!(degree, 2);
1184 Ok(())
1185 }
1186
1187 #[test]
1188 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1189 let (_tmp, conn) = setup_db()?;
1190 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1191 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1192 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1193
1194 let rel1 = NewRelationship {
1195 source: "rc-a".to_string(),
1196 target: "rc-b".to_string(),
1197 relation: "uses".to_string(),
1198 strength: 0.5,
1199 description: None,
1200 };
1201 let rel2 = NewRelationship {
1202 source: "rc-c".to_string(),
1203 target: "rc-a".to_string(),
1204 relation: "depends_on".to_string(),
1205 strength: 0.5,
1206 description: None,
1207 };
1208 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1209 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1210
1211 recalculate_degree(&conn, id_a)?;
1212
1213 let degree: i64 = conn.query_row(
1214 "SELECT degree FROM entities WHERE id = ?1",
1215 params![id_a],
1216 |r| r.get(0),
1217 )?;
1218 assert_eq!(
1219 degree, 2,
1220 "rc-a appears in two relationships (source+target)"
1221 );
1222 Ok(())
1223 }
1224
1225 #[test]
1230 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1231 let (_tmp, conn) = setup_db()?;
1232 let memory_id = insert_memory(&conn)?;
1233 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1234 link_memory_entity(&conn, memory_id, entity_id)?;
1235
1236 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1237 assert!(!orfas.contains(&entity_id));
1238 Ok(())
1239 }
1240
1241 #[test]
1242 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1243 let (_tmp, conn) = setup_db()?;
1244 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1245
1246 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1247 assert!(orfas.contains(&entity_id));
1248 Ok(())
1249 }
1250
1251 #[test]
1252 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1253 let (_tmp, conn) = setup_db()?;
1254 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1255 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1256
1257 let orfas = find_orphan_entity_ids(&conn, None)?;
1258 assert!(orfas.contains(&id1));
1259 assert!(orfas.contains(&id2));
1260 Ok(())
1261 }
1262
1263 #[test]
1268 fn test_list_entities_with_namespace() -> TestResult {
1269 let (_tmp, conn) = setup_db()?;
1270 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1271 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1272 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1273
1274 let lista = list_entities(&conn, Some("le-ns"))?;
1275 assert_eq!(lista.len(), 2);
1276 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1277 Ok(())
1278 }
1279
1280 #[test]
1281 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1282 let (_tmp, conn) = setup_db()?;
1283 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1284 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1285
1286 let lista = list_entities(&conn, None)?;
1287 assert!(lista.len() >= 2);
1288 Ok(())
1289 }
1290
1291 #[test]
1292 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1293 let (_tmp, conn) = setup_db()?;
1294 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1295 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1296
1297 let rel = NewRelationship {
1298 source: "lr-a".to_string(),
1299 target: "lr-b".to_string(),
1300 relation: "uses".to_string(),
1301 strength: 0.5,
1302 description: None,
1303 };
1304 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1305
1306 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1307 assert!(!lista.is_empty());
1308 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1309 Ok(())
1310 }
1311
1312 #[test]
1317 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1318 let (_tmp, conn) = setup_db()?;
1319 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1320 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1321
1322 let rel = NewRelationship {
1323 source: "dr-a".to_string(),
1324 target: "dr-b".to_string(),
1325 relation: "uses".to_string(),
1326 strength: 0.5,
1327 description: None,
1328 };
1329 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1330
1331 delete_relationship_by_id(&conn, rel_id)?;
1332
1333 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1334 assert!(encontrada.is_none(), "relationship must have been removed");
1335 Ok(())
1336 }
1337
1338 #[test]
1339 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1340 let (_tmp, conn) = setup_db()?;
1341 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1342 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1343
1344 let (rel_id, created) =
1345 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1346 assert!(rel_id > 0);
1347 assert!(created);
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1353 let (_tmp, conn) = setup_db()?;
1354 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1355 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1356
1357 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1358 let (_, created) =
1359 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1360 assert!(
1361 !created,
1362 "second call must return the existing relationship"
1363 );
1364 Ok(())
1365 }
1366
1367 #[test]
1372 fn accepts_type_field_as_alias() -> TestResult {
1373 let json = r#"{"name": "X", "type": "concept"}"#;
1374 let ent: NewEntity = serde_json::from_str(json)?;
1375 assert_eq!(ent.entity_type, EntityType::Concept);
1376 Ok(())
1377 }
1378
1379 #[test]
1380 fn accepts_canonical_entity_type_field() -> TestResult {
1381 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1382 let ent: NewEntity = serde_json::from_str(json)?;
1383 assert_eq!(ent.entity_type, EntityType::Concept);
1384 Ok(())
1385 }
1386
1387 #[test]
1388 fn both_fields_present_yields_duplicate_error() {
1389 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1391 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1392 assert!(
1393 resultado.is_err(),
1394 "both fields in the same JSON are a duplicate"
1395 );
1396 }
1397
1398 #[test]
1399 fn validate_entity_name_accepts_valid() {
1400 assert!(validate_entity_name("rust-lang").is_ok());
1401 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1402 assert!(validate_entity_name("ab").is_ok());
1403 }
1404
1405 #[test]
1406 fn validate_entity_name_rejects_short() {
1407 assert!(validate_entity_name("a").is_err());
1408 assert!(validate_entity_name("").is_err());
1409 }
1410
1411 #[test]
1412 fn validate_entity_name_rejects_newlines() {
1413 assert!(validate_entity_name("foo\nbar").is_err());
1414 assert!(validate_entity_name("foo\rbar").is_err());
1415 }
1416
1417 #[test]
1418 fn validate_entity_name_rejects_short_allcaps() {
1419 assert!(validate_entity_name("RAM").is_err());
1420 assert!(validate_entity_name("NAO").is_err());
1421 assert!(validate_entity_name("OK").is_err());
1422 }
1423
1424 #[test]
1425 fn validate_entity_name_accepts_long_allcaps() {
1426 assert!(validate_entity_name("SQLITE").is_ok());
1427 assert!(validate_entity_name("GRAPHRAG").is_ok());
1428 }
1429
1430 #[test]
1431 fn validate_entity_name_accepts_mixed_case() {
1432 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1435
1436 #[test]
1438 fn test_unlink_memory_entity_removes_single_binding() -> TestResult {
1439 let (_tmp, conn) = setup_db()?;
1440 let memory_id = insert_memory(&conn)?;
1441 let e1 = upsert_entity(&conn, "global", &new_entity_helper("entidade-um"))?;
1442 let e2 = upsert_entity(&conn, "global", &new_entity_helper("entidade-dois"))?;
1443 link_memory_entity(&conn, memory_id, e1)?;
1444 link_memory_entity(&conn, memory_id, e2)?;
1445
1446 let removed = unlink_memory_entity(&conn, memory_id, e1)?;
1447 assert_eq!(removed, 1);
1448
1449 let remaining: i64 = conn.query_row(
1451 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1452 params![memory_id],
1453 |r| r.get(0),
1454 )?;
1455 assert_eq!(remaining, 1);
1456
1457 assert_eq!(unlink_memory_entity(&conn, memory_id, e1)?, 0);
1459 Ok(())
1460 }
1461
1462 #[test]
1464 fn test_clear_memory_graph_bindings_clears_all() -> TestResult {
1465 let (_tmp, conn) = setup_db()?;
1466 let memory_id = insert_memory(&conn)?;
1467 let e1 = upsert_entity(&conn, "global", &new_entity_helper("alpha-node"))?;
1468 let e2 = upsert_entity(&conn, "global", &new_entity_helper("beta-node"))?;
1469 link_memory_entity(&conn, memory_id, e1)?;
1470 link_memory_entity(&conn, memory_id, e2)?;
1471 let rel = NewRelationship {
1472 source: "alpha-node".to_string(),
1473 target: "beta-node".to_string(),
1474 relation: "related".to_string(),
1475 strength: 0.5,
1476 description: None,
1477 };
1478 let rel_id = upsert_relationship(&conn, "global", e1, e2, &rel)?;
1479 link_memory_relationship(&conn, memory_id, rel_id)?;
1480
1481 let (e_removed, r_removed) = clear_memory_graph_bindings(&conn, memory_id)?;
1482 assert_eq!(e_removed, 2);
1483 assert_eq!(r_removed, 1);
1484
1485 let ent_left: i64 = conn.query_row(
1486 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1487 params![memory_id],
1488 |r| r.get(0),
1489 )?;
1490 let rel_left: i64 = conn.query_row(
1491 "SELECT COUNT(*) FROM memory_relationships WHERE memory_id = ?1",
1492 params![memory_id],
1493 |r| r.get(0),
1494 )?;
1495 assert_eq!(ent_left, 0);
1496 assert_eq!(rel_left, 0);
1497 Ok(())
1498 }
1499}