1use crate::embedder::f32_to_bytes;
8use crate::entity_type::EntityType;
9use crate::errors::AppError;
10use crate::storage::utils::with_busy_retry;
11use rusqlite::{params, Connection};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Serialize, Deserialize, Clone)]
19#[serde(deny_unknown_fields)]
20pub struct NewEntity {
21 pub name: String,
22 #[serde(alias = "type")]
23 pub entity_type: EntityType,
24 pub description: Option<String>,
25}
26
27#[derive(Debug, Serialize, Deserialize, Clone)]
32#[serde(deny_unknown_fields)]
33pub struct NewRelationship {
34 #[serde(alias = "from")]
35 pub source: String,
36 #[serde(alias = "to")]
37 pub target: String,
38 pub relation: String,
39 pub strength: f64,
40 pub description: Option<String>,
41}
42
43pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
52 if name.len() < 2 {
53 return Err(AppError::Validation(format!(
54 "entity name '{name}' must be at least 2 characters"
55 )));
56 }
57 if name.contains('\n') || name.contains('\r') {
58 return Err(AppError::Validation(
59 "entity name must not contain newline characters".to_string(),
60 ));
61 }
62 if name.len() <= 4
63 && name
64 .chars()
65 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
66 {
67 return Err(AppError::Validation(format!(
68 "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
69 )));
70 }
71 Ok(())
72}
73
74pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
83 validate_entity_name(&e.name)?;
84 conn.execute(
85 "INSERT INTO entities (namespace, name, type, description)
86 VALUES (?1, ?2, ?3, ?4)
87 ON CONFLICT(namespace, name) DO UPDATE SET
88 type = excluded.type,
89 description = COALESCE(excluded.description, entities.description),
90 updated_at = unixepoch()",
91 params![namespace, e.name, e.entity_type, e.description],
92 )?;
93 let id: i64 = conn.query_row(
94 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
95 params![namespace, e.name],
96 |r| r.get(0),
97 )?;
98 Ok(id)
99}
100
101pub fn upsert_entity_vec(
113 conn: &Connection,
114 entity_id: i64,
115 namespace: &str,
116 entity_type: EntityType,
117 embedding: &[f32],
118 name: &str,
119) -> Result<(), AppError> {
120 let embedding_bytes = f32_to_bytes(embedding);
123 with_busy_retry(|| {
124 conn.execute(
125 "DELETE FROM vec_entities WHERE entity_id = ?1",
126 params![entity_id],
127 )?;
128 conn.execute(
129 "INSERT INTO vec_entities(entity_id, namespace, type, embedding, name)
130 VALUES (?1, ?2, ?3, ?4, ?5)",
131 params![entity_id, namespace, entity_type, &embedding_bytes, name],
132 )?;
133 Ok(())
134 })
135}
136
137pub fn upsert_relationship(
146 conn: &Connection,
147 namespace: &str,
148 source_id: i64,
149 target_id: i64,
150 rel: &NewRelationship,
151) -> Result<i64, AppError> {
152 conn.execute(
153 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
154 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
155 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
156 weight = excluded.weight,
157 description = COALESCE(excluded.description, relationships.description)",
158 params![
159 namespace,
160 source_id,
161 target_id,
162 rel.relation,
163 rel.strength,
164 rel.description
165 ],
166 )?;
167 let id: i64 = conn.query_row(
168 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
169 params![source_id, target_id, rel.relation],
170 |r| r.get(0),
171 )?;
172 Ok(id)
173}
174
175pub fn link_memory_entity(
176 conn: &Connection,
177 memory_id: i64,
178 entity_id: i64,
179) -> Result<(), AppError> {
180 conn.execute(
181 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
182 params![memory_id, entity_id],
183 )?;
184 Ok(())
185}
186
187pub fn link_memory_relationship(
188 conn: &Connection,
189 memory_id: i64,
190 rel_id: i64,
191) -> Result<(), AppError> {
192 conn.execute(
193 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
194 params![memory_id, rel_id],
195 )?;
196 Ok(())
197}
198
199pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
200 conn.execute(
201 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
202 params![entity_id],
203 )?;
204 Ok(())
205}
206
207pub fn find_entity_id(
209 conn: &Connection,
210 namespace: &str,
211 name: &str,
212) -> Result<Option<i64>, AppError> {
213 let mut stmt =
214 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
215 match stmt.query_row(params![namespace, name], |r| r.get::<_, i64>(0)) {
216 Ok(id) => Ok(Some(id)),
217 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
218 Err(e) => Err(AppError::Database(e)),
219 }
220}
221
222#[derive(Debug, Serialize)]
224pub struct RelationshipRow {
225 pub id: i64,
226 pub namespace: String,
227 pub source_id: i64,
228 pub target_id: i64,
229 pub relation: String,
230 pub weight: f64,
231 pub description: Option<String>,
232}
233
234pub fn find_relationship(
236 conn: &Connection,
237 source_id: i64,
238 target_id: i64,
239 relation: &str,
240) -> Result<Option<RelationshipRow>, AppError> {
241 let mut stmt = conn.prepare_cached(
242 "SELECT id, namespace, source_id, target_id, relation, weight, description
243 FROM relationships
244 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
245 )?;
246 match stmt.query_row(params![source_id, target_id, relation], |r| {
247 Ok(RelationshipRow {
248 id: r.get(0)?,
249 namespace: r.get(1)?,
250 source_id: r.get(2)?,
251 target_id: r.get(3)?,
252 relation: r.get(4)?,
253 weight: r.get(5)?,
254 description: r.get(6)?,
255 })
256 }) {
257 Ok(row) => Ok(Some(row)),
258 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
259 Err(e) => Err(AppError::Database(e)),
260 }
261}
262
263pub fn create_or_fetch_relationship(
266 conn: &Connection,
267 namespace: &str,
268 source_id: i64,
269 target_id: i64,
270 relation: &str,
271 weight: f64,
272 description: Option<&str>,
273) -> Result<(i64, bool), AppError> {
274 let existing = find_relationship(conn, source_id, target_id, relation)?;
276 if let Some(row) = existing {
277 return Ok((row.id, false));
278 }
279 conn.execute(
280 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
281 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
282 params![
283 namespace,
284 source_id,
285 target_id,
286 relation,
287 weight,
288 description
289 ],
290 )?;
291 let id: i64 = conn.query_row(
292 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
293 params![source_id, target_id, relation],
294 |r| r.get(0),
295 )?;
296 Ok((id, true))
297}
298
299pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
301 conn.execute(
302 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
303 params![relationship_id],
304 )?;
305 conn.execute(
306 "DELETE FROM relationships WHERE id = ?1",
307 params![relationship_id],
308 )?;
309 Ok(())
310}
311
312pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
314 conn.execute(
315 "UPDATE entities
316 SET degree = (SELECT COUNT(*) FROM relationships
317 WHERE source_id = entities.id OR target_id = entities.id)
318 WHERE id = ?1",
319 params![entity_id],
320 )?;
321 Ok(())
322}
323
324#[derive(Debug, Serialize, Clone)]
326pub struct EntityNode {
327 pub id: i64,
328 pub name: String,
329 pub namespace: String,
330 pub kind: String,
331}
332
333pub fn list_entities(
335 conn: &Connection,
336 namespace: Option<&str>,
337) -> Result<Vec<EntityNode>, AppError> {
338 if let Some(ns) = namespace {
339 let mut stmt = conn.prepare(
340 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
341 )?;
342 let rows = stmt
343 .query_map(params![ns], |r| {
344 Ok(EntityNode {
345 id: r.get(0)?,
346 name: r.get(1)?,
347 namespace: r.get(2)?,
348 kind: r.get(3)?,
349 })
350 })?
351 .collect::<Result<Vec<_>, _>>()?;
352 Ok(rows)
353 } else {
354 let mut stmt =
355 conn.prepare("SELECT id, name, namespace, type FROM entities ORDER BY namespace, id")?;
356 let rows = stmt
357 .query_map([], |r| {
358 Ok(EntityNode {
359 id: r.get(0)?,
360 name: r.get(1)?,
361 namespace: r.get(2)?,
362 kind: r.get(3)?,
363 })
364 })?
365 .collect::<Result<Vec<_>, _>>()?;
366 Ok(rows)
367 }
368}
369
370pub fn list_relationships_by_namespace(
372 conn: &Connection,
373 namespace: Option<&str>,
374) -> Result<Vec<RelationshipRow>, AppError> {
375 if let Some(ns) = namespace {
376 let mut stmt = conn.prepare(
377 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
378 FROM relationships r
379 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
380 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
381 ORDER BY r.id",
382 )?;
383 let rows = stmt
384 .query_map(params![ns], |r| {
385 Ok(RelationshipRow {
386 id: r.get(0)?,
387 namespace: r.get(1)?,
388 source_id: r.get(2)?,
389 target_id: r.get(3)?,
390 relation: r.get(4)?,
391 weight: r.get(5)?,
392 description: r.get(6)?,
393 })
394 })?
395 .collect::<Result<Vec<_>, _>>()?;
396 Ok(rows)
397 } else {
398 let mut stmt = conn.prepare(
399 "SELECT id, namespace, source_id, target_id, relation, weight, description
400 FROM relationships ORDER BY id",
401 )?;
402 let rows = stmt
403 .query_map([], |r| {
404 Ok(RelationshipRow {
405 id: r.get(0)?,
406 namespace: r.get(1)?,
407 source_id: r.get(2)?,
408 target_id: r.get(3)?,
409 relation: r.get(4)?,
410 weight: r.get(5)?,
411 description: r.get(6)?,
412 })
413 })?
414 .collect::<Result<Vec<_>, _>>()?;
415 Ok(rows)
416 }
417}
418
419pub fn find_orphan_entity_ids(
421 conn: &Connection,
422 namespace: Option<&str>,
423) -> Result<Vec<i64>, AppError> {
424 if let Some(ns) = namespace {
425 let mut stmt = conn.prepare(
426 "SELECT e.id FROM entities e
427 WHERE e.namespace = ?1
428 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
429 AND NOT EXISTS (
430 SELECT 1 FROM relationships r
431 WHERE r.source_id = e.id OR r.target_id = e.id
432 )",
433 )?;
434 let ids = stmt
435 .query_map(params![ns], |r| r.get::<_, i64>(0))?
436 .collect::<Result<Vec<_>, _>>()?;
437 Ok(ids)
438 } else {
439 let mut stmt = conn.prepare(
440 "SELECT e.id FROM entities e
441 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
442 AND NOT EXISTS (
443 SELECT 1 FROM relationships r
444 WHERE r.source_id = e.id OR r.target_id = e.id
445 )",
446 )?;
447 let ids = stmt
448 .query_map([], |r| r.get::<_, i64>(0))?
449 .collect::<Result<Vec<_>, _>>()?;
450 Ok(ids)
451 }
452}
453
454pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
456 if entity_ids.is_empty() {
457 return Ok(0);
458 }
459 let mut removed = 0usize;
460 for id in entity_ids {
461 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
463 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
464 removed += affected;
465 }
466 Ok(removed)
467}
468
469pub fn count_relationships_by_relation(
478 conn: &Connection,
479 namespace: &str,
480 relation: &str,
481) -> Result<usize, AppError> {
482 let count: i64 = conn.query_row(
483 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
484 params![namespace, relation],
485 |r| r.get(0),
486 )?;
487 Ok(count as usize)
488}
489
490pub fn list_entity_names_by_relation(
499 conn: &Connection,
500 namespace: &str,
501 relation: &str,
502) -> Result<Vec<String>, AppError> {
503 let mut stmt = conn.prepare(
504 "SELECT DISTINCT e.name FROM entities e
505 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
506 WHERE r.namespace = ?1 AND r.relation = ?2
507 ORDER BY e.name",
508 )?;
509 let names: Vec<String> = stmt
510 .query_map(params![namespace, relation], |row| row.get(0))?
511 .collect::<Result<Vec<_>, _>>()?;
512 Ok(names)
513}
514
515pub fn delete_relationships_by_relation(
526 conn: &Connection,
527 namespace: &str,
528 relation: &str,
529) -> Result<(usize, Vec<i64>), AppError> {
530 let mut stmt = conn.prepare(
532 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
533 UNION
534 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
535 )?;
536 let entity_ids: Vec<i64> = stmt
537 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
538 .collect::<Result<Vec<_>, _>>()?;
539
540 let mut id_stmt =
542 conn.prepare("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
543 let rel_ids: Vec<i64> = id_stmt
544 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
545 .collect::<Result<Vec<_>, _>>()?;
546
547 let mut total_deleted: usize = 0;
549 for chunk in rel_ids.chunks(1000) {
550 for &rel_id in chunk {
551 conn.execute(
552 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
553 params![rel_id],
554 )?;
555 let affected =
556 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
557 total_deleted += affected;
558 }
559 }
560
561 for &eid in &entity_ids {
563 recalculate_degree(conn, eid)?;
564 }
565
566 Ok((total_deleted, entity_ids))
567}
568
569pub fn knn_search(
570 conn: &Connection,
571 embedding: &[f32],
572 namespace: &str,
573 k: usize,
574) -> Result<Vec<(i64, f32)>, AppError> {
575 let bytes = f32_to_bytes(embedding);
576 let mut stmt = conn.prepare(
577 "SELECT entity_id, distance FROM vec_entities
578 WHERE embedding MATCH ?1 AND namespace = ?2
579 ORDER BY distance LIMIT ?3",
580 )?;
581 let rows = stmt
582 .query_map(params![bytes, namespace, k as i64], |r| {
583 Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
584 })?
585 .collect::<Result<Vec<_>, _>>()?;
586 Ok(rows)
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::constants::EMBEDDING_DIM;
593 use crate::entity_type::EntityType;
594 use crate::storage::connection::register_vec_extension;
595 use rusqlite::Connection;
596 use tempfile::TempDir;
597
598 type TestResult = Result<(), Box<dyn std::error::Error>>;
599
600 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
601 register_vec_extension();
602 let tmp = TempDir::new()?;
603 let db_path = tmp.path().join("test.db");
604 let mut conn = Connection::open(&db_path)?;
605 crate::migrations::runner().run(&mut conn)?;
606 Ok((tmp, conn))
607 }
608
609 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
610 conn.execute(
611 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
612 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
613 [],
614 )?;
615 Ok(conn.last_insert_rowid())
616 }
617
618 fn new_entity_helper(name: &str) -> NewEntity {
619 NewEntity {
620 name: name.to_string(),
621 entity_type: EntityType::Project,
622 description: None,
623 }
624 }
625
626 fn embedding_zero() -> Vec<f32> {
627 vec![0.0f32; EMBEDDING_DIM]
628 }
629
630 #[test]
635 fn test_upsert_entity_creates_new() -> TestResult {
636 let (_tmp, conn) = setup_db()?;
637 let e = new_entity_helper("projeto-alpha");
638 let id = upsert_entity(&conn, "global", &e)?;
639 assert!(id > 0);
640 Ok(())
641 }
642
643 #[test]
644 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
645 let (_tmp, conn) = setup_db()?;
646 let e = new_entity_helper("projeto-beta");
647 let id1 = upsert_entity(&conn, "global", &e)?;
648 let id2 = upsert_entity(&conn, "global", &e)?;
649 assert_eq!(id1, id2);
650 Ok(())
651 }
652
653 #[test]
654 fn test_upsert_entity_updates_description() -> TestResult {
655 let (_tmp, conn) = setup_db()?;
656 let e1 = new_entity_helper("projeto-gamma");
657 let id1 = upsert_entity(&conn, "global", &e1)?;
658
659 let e2 = NewEntity {
660 name: "projeto-gamma".to_string(),
661 entity_type: EntityType::Tool,
662 description: Some("nova desc".to_string()),
663 };
664 let id2 = upsert_entity(&conn, "global", &e2)?;
665 assert_eq!(id1, id2);
666
667 let desc: Option<String> = conn.query_row(
668 "SELECT description FROM entities WHERE id = ?1",
669 params![id1],
670 |r| r.get(0),
671 )?;
672 assert_eq!(desc.as_deref(), Some("nova desc"));
673 Ok(())
674 }
675
676 #[test]
677 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
678 let (_tmp, conn) = setup_db()?;
679 let e = new_entity_helper("compartilhada");
680 let id1 = upsert_entity(&conn, "ns1", &e)?;
681 let id2 = upsert_entity(&conn, "ns2", &e)?;
682 assert_ne!(id1, id2);
683 Ok(())
684 }
685
686 #[test]
691 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
692 let (_tmp, conn) = setup_db()?;
693 let e = new_entity_helper("vec-nova");
694 let entity_id = upsert_entity(&conn, "global", &e)?;
695 let emb = embedding_zero();
696
697 let result = upsert_entity_vec(
698 &conn,
699 entity_id,
700 "global",
701 EntityType::Project,
702 &emb,
703 "vec-nova",
704 );
705 assert!(result.is_ok(), "first insertion must succeed");
706
707 let count: i64 = conn.query_row(
708 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
709 params![entity_id],
710 |r| r.get(0),
711 )?;
712 assert_eq!(count, 1, "must have exactly one row after insertion");
713 Ok(())
714 }
715
716 #[test]
717 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
718 let (_tmp, conn) = setup_db()?;
720 let e = new_entity_helper("vec-existente");
721 let entity_id = upsert_entity(&conn, "global", &e)?;
722 let emb = embedding_zero();
723
724 upsert_entity_vec(
725 &conn,
726 entity_id,
727 "global",
728 EntityType::Project,
729 &emb,
730 "vec-existente",
731 )?;
732
733 let result = upsert_entity_vec(
735 &conn,
736 entity_id,
737 "global",
738 EntityType::Tool,
739 &emb,
740 "vec-existente",
741 );
742 assert!(
743 result.is_ok(),
744 "second insertion (replace) must succeed: {result:?}"
745 );
746
747 let count: i64 = conn.query_row(
748 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
749 params![entity_id],
750 |r| r.get(0),
751 )?;
752 assert_eq!(count, 1, "must have exactly one row after replacement");
753 Ok(())
754 }
755
756 #[test]
757 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
758 let (_tmp, conn) = setup_db()?;
759 let emb = embedding_zero();
760
761 for i in 0..3i64 {
762 let nome = format!("ent-{i}");
763 let e = new_entity_helper(&nome);
764 let entity_id = upsert_entity(&conn, "global", &e)?;
765 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
766 }
767
768 let count: i64 = conn.query_row("SELECT COUNT(*) FROM vec_entities", [], |r| r.get(0))?;
769 assert_eq!(count, 3, "must have three distinct rows in vec_entities");
770 Ok(())
771 }
772
773 #[test]
778 fn test_find_entity_id_existing_returns_some() -> TestResult {
779 let (_tmp, conn) = setup_db()?;
780 let e = new_entity_helper("entidade-busca");
781 let id_inserido = upsert_entity(&conn, "global", &e)?;
782 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
783 assert_eq!(id_encontrado, Some(id_inserido));
784 Ok(())
785 }
786
787 #[test]
788 fn test_find_entity_id_missing_returns_none() -> TestResult {
789 let (_tmp, conn) = setup_db()?;
790 let id = find_entity_id(&conn, "global", "nao-existe")?;
791 assert_eq!(id, None);
792 Ok(())
793 }
794
795 #[test]
800 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
801 let (_tmp, conn) = setup_db()?;
802 let removed = delete_entities_by_ids(&conn, &[])?;
803 assert_eq!(removed, 0);
804 Ok(())
805 }
806
807 #[test]
808 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
809 let (_tmp, conn) = setup_db()?;
810 let e = new_entity_helper("to-delete");
811 let entity_id = upsert_entity(&conn, "global", &e)?;
812
813 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
814 assert_eq!(removed, 1);
815
816 let id = find_entity_id(&conn, "global", "to-delete")?;
817 assert_eq!(id, None, "entity must have been removed");
818 Ok(())
819 }
820
821 #[test]
822 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
823 let (_tmp, conn) = setup_db()?;
824 let removed = delete_entities_by_ids(&conn, &[9999])?;
825 assert_eq!(removed, 0);
826 Ok(())
827 }
828
829 #[test]
830 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
831 let (_tmp, conn) = setup_db()?;
832 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
833 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
834 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
835
836 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
837 assert_eq!(removed, 2);
838
839 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
840 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
841 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
842 let _ = id3;
843 Ok(())
844 }
845
846 #[test]
847 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
848 let (_tmp, conn) = setup_db()?;
849 let e = new_entity_helper("del-com-vec");
850 let entity_id = upsert_entity(&conn, "global", &e)?;
851 let emb = embedding_zero();
852 upsert_entity_vec(
853 &conn,
854 entity_id,
855 "global",
856 EntityType::Project,
857 &emb,
858 "del-com-vec",
859 )?;
860
861 let count_antes: i64 = conn.query_row(
862 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
863 params![entity_id],
864 |r| r.get(0),
865 )?;
866 assert_eq!(count_antes, 1);
867
868 delete_entities_by_ids(&conn, &[entity_id])?;
869
870 let count_depois: i64 = conn.query_row(
871 "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
872 params![entity_id],
873 |r| r.get(0),
874 )?;
875 assert_eq!(
876 count_depois, 0,
877 "vec_entities deve ser limpo junto com entities"
878 );
879 Ok(())
880 }
881
882 #[test]
887 fn test_upsert_relationship_creates_new() -> TestResult {
888 let (_tmp, conn) = setup_db()?;
889 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
890 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
891
892 let rel = NewRelationship {
893 source: "rel-a".to_string(),
894 target: "rel-b".to_string(),
895 relation: "uses".to_string(),
896 strength: 0.8,
897 description: None,
898 };
899 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
900 assert!(rel_id > 0);
901 Ok(())
902 }
903
904 #[test]
905 fn test_upsert_relationship_idempotent() -> TestResult {
906 let (_tmp, conn) = setup_db()?;
907 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
908 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
909
910 let rel = NewRelationship {
911 source: "idem-a".to_string(),
912 target: "idem-b".to_string(),
913 relation: "uses".to_string(),
914 strength: 0.5,
915 description: None,
916 };
917 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
918 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
919 assert_eq!(id1, id2);
920 Ok(())
921 }
922
923 #[test]
924 fn test_find_relationship_existing() -> TestResult {
925 let (_tmp, conn) = setup_db()?;
926 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
927 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
928
929 let rel = NewRelationship {
930 source: "fr-a".to_string(),
931 target: "fr-b".to_string(),
932 relation: "depends_on".to_string(),
933 strength: 0.7,
934 description: None,
935 };
936 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
937
938 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
939 let row = encontrada.ok_or("relationship should exist")?;
940 assert_eq!(row.source_id, id_a);
941 assert_eq!(row.target_id, id_b);
942 assert!((row.weight - 0.7).abs() < 1e-9);
943 Ok(())
944 }
945
946 #[test]
947 fn test_find_relationship_missing_returns_none() -> TestResult {
948 let (_tmp, conn) = setup_db()?;
949 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
950 assert!(resultado.is_none());
951 Ok(())
952 }
953
954 #[test]
959 fn test_link_memory_entity_idempotent() -> TestResult {
960 let (_tmp, conn) = setup_db()?;
961 let memory_id = insert_memory(&conn)?;
962 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
963
964 link_memory_entity(&conn, memory_id, entity_id)?;
965 let resultado = link_memory_entity(&conn, memory_id, entity_id);
966 assert!(
967 resultado.is_ok(),
968 "INSERT OR IGNORE must not fail on duplicate"
969 );
970 Ok(())
971 }
972
973 #[test]
974 fn test_link_memory_relationship_idempotent() -> TestResult {
975 let (_tmp, conn) = setup_db()?;
976 let memory_id = insert_memory(&conn)?;
977 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
978 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
979
980 let rel = NewRelationship {
981 source: "mr-a".to_string(),
982 target: "mr-b".to_string(),
983 relation: "uses".to_string(),
984 strength: 0.5,
985 description: None,
986 };
987 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
988
989 link_memory_relationship(&conn, memory_id, rel_id)?;
990 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
991 assert!(
992 resultado.is_ok(),
993 "INSERT OR IGNORE must not fail on duplicate"
994 );
995 Ok(())
996 }
997
998 #[test]
1003 fn test_increment_degree_increases_counter() -> TestResult {
1004 let (_tmp, conn) = setup_db()?;
1005 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1006
1007 increment_degree(&conn, entity_id)?;
1008 increment_degree(&conn, entity_id)?;
1009
1010 let degree: i64 = conn.query_row(
1011 "SELECT degree FROM entities WHERE id = ?1",
1012 params![entity_id],
1013 |r| r.get(0),
1014 )?;
1015 assert_eq!(degree, 2);
1016 Ok(())
1017 }
1018
1019 #[test]
1020 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1021 let (_tmp, conn) = setup_db()?;
1022 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1023 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1024 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1025
1026 let rel1 = NewRelationship {
1027 source: "rc-a".to_string(),
1028 target: "rc-b".to_string(),
1029 relation: "uses".to_string(),
1030 strength: 0.5,
1031 description: None,
1032 };
1033 let rel2 = NewRelationship {
1034 source: "rc-c".to_string(),
1035 target: "rc-a".to_string(),
1036 relation: "depends_on".to_string(),
1037 strength: 0.5,
1038 description: None,
1039 };
1040 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1041 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1042
1043 recalculate_degree(&conn, id_a)?;
1044
1045 let degree: i64 = conn.query_row(
1046 "SELECT degree FROM entities WHERE id = ?1",
1047 params![id_a],
1048 |r| r.get(0),
1049 )?;
1050 assert_eq!(
1051 degree, 2,
1052 "rc-a appears in two relationships (source+target)"
1053 );
1054 Ok(())
1055 }
1056
1057 #[test]
1062 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1063 let (_tmp, conn) = setup_db()?;
1064 let memory_id = insert_memory(&conn)?;
1065 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1066 link_memory_entity(&conn, memory_id, entity_id)?;
1067
1068 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1069 assert!(!orfas.contains(&entity_id));
1070 Ok(())
1071 }
1072
1073 #[test]
1074 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1075 let (_tmp, conn) = setup_db()?;
1076 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1077
1078 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1079 assert!(orfas.contains(&entity_id));
1080 Ok(())
1081 }
1082
1083 #[test]
1084 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1085 let (_tmp, conn) = setup_db()?;
1086 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1087 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1088
1089 let orfas = find_orphan_entity_ids(&conn, None)?;
1090 assert!(orfas.contains(&id1));
1091 assert!(orfas.contains(&id2));
1092 Ok(())
1093 }
1094
1095 #[test]
1100 fn test_list_entities_with_namespace() -> TestResult {
1101 let (_tmp, conn) = setup_db()?;
1102 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1103 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1104 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1105
1106 let lista = list_entities(&conn, Some("le-ns"))?;
1107 assert_eq!(lista.len(), 2);
1108 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1109 Ok(())
1110 }
1111
1112 #[test]
1113 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1114 let (_tmp, conn) = setup_db()?;
1115 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1116 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1117
1118 let lista = list_entities(&conn, None)?;
1119 assert!(lista.len() >= 2);
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1125 let (_tmp, conn) = setup_db()?;
1126 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1127 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1128
1129 let rel = NewRelationship {
1130 source: "lr-a".to_string(),
1131 target: "lr-b".to_string(),
1132 relation: "uses".to_string(),
1133 strength: 0.5,
1134 description: None,
1135 };
1136 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1137
1138 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1139 assert!(!lista.is_empty());
1140 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1141 Ok(())
1142 }
1143
1144 #[test]
1149 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1150 let (_tmp, conn) = setup_db()?;
1151 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1152 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1153
1154 let rel = NewRelationship {
1155 source: "dr-a".to_string(),
1156 target: "dr-b".to_string(),
1157 relation: "uses".to_string(),
1158 strength: 0.5,
1159 description: None,
1160 };
1161 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1162
1163 delete_relationship_by_id(&conn, rel_id)?;
1164
1165 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1166 assert!(encontrada.is_none(), "relationship must have been removed");
1167 Ok(())
1168 }
1169
1170 #[test]
1171 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1172 let (_tmp, conn) = setup_db()?;
1173 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1174 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1175
1176 let (rel_id, created) =
1177 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1178 assert!(rel_id > 0);
1179 assert!(created);
1180 Ok(())
1181 }
1182
1183 #[test]
1184 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1185 let (_tmp, conn) = setup_db()?;
1186 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1187 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1188
1189 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1190 let (_, created) =
1191 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1192 assert!(
1193 !created,
1194 "second call must return the existing relationship"
1195 );
1196 Ok(())
1197 }
1198
1199 #[test]
1204 fn accepts_type_field_as_alias() -> TestResult {
1205 let json = r#"{"name": "X", "type": "concept"}"#;
1206 let ent: NewEntity = serde_json::from_str(json)?;
1207 assert_eq!(ent.entity_type, EntityType::Concept);
1208 Ok(())
1209 }
1210
1211 #[test]
1212 fn accepts_canonical_entity_type_field() -> TestResult {
1213 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1214 let ent: NewEntity = serde_json::from_str(json)?;
1215 assert_eq!(ent.entity_type, EntityType::Concept);
1216 Ok(())
1217 }
1218
1219 #[test]
1220 fn both_fields_present_yields_duplicate_error() {
1221 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1223 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1224 assert!(
1225 resultado.is_err(),
1226 "both fields in the same JSON are a duplicate"
1227 );
1228 }
1229
1230 #[test]
1231 fn validate_entity_name_accepts_valid() {
1232 assert!(validate_entity_name("rust-lang").is_ok());
1233 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1234 assert!(validate_entity_name("ab").is_ok());
1235 }
1236
1237 #[test]
1238 fn validate_entity_name_rejects_short() {
1239 assert!(validate_entity_name("a").is_err());
1240 assert!(validate_entity_name("").is_err());
1241 }
1242
1243 #[test]
1244 fn validate_entity_name_rejects_newlines() {
1245 assert!(validate_entity_name("foo\nbar").is_err());
1246 assert!(validate_entity_name("foo\rbar").is_err());
1247 }
1248
1249 #[test]
1250 fn validate_entity_name_rejects_short_allcaps() {
1251 assert!(validate_entity_name("RAM").is_err());
1252 assert!(validate_entity_name("NAO").is_err());
1253 assert!(validate_entity_name("OK").is_err());
1254 }
1255
1256 #[test]
1257 fn validate_entity_name_accepts_long_allcaps() {
1258 assert!(validate_entity_name("SQLITE").is_ok());
1259 assert!(validate_entity_name("GRAPHRAG").is_ok());
1260 }
1261
1262 #[test]
1263 fn validate_entity_name_accepts_mixed_case() {
1264 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1267}