Skip to main content

sqlite_graphrag/storage/
entities.rs

1//! Persistence layer for entities, relationships and their junction tables.
2//!
3//! The entity graph mirrors the conceptual content of memories: `entities`
4//! holds nodes, `relationships` holds typed edges and `memory_entities` and
5//! `memory_relationships` connect each memory to the graph slice it emitted.
6
7use 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/// Input payload used to upsert a single entity.
15///
16/// `name` is normalized to kebab-case by the caller. `description` is
17/// optional and preserved across upserts when the new value is `None`.
18#[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/// Input payload used to upsert a typed relationship between entities.
28///
29/// `strength` must lie within `[0.0, 1.0]` and is mapped to the `weight`
30/// column of the `relationships` table.
31#[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
43/// Upserts an entity and returns its primary key.
44///
45/// Uses `ON CONFLICT(namespace, name)` to keep one row per entity within a
46/// namespace, refreshing `type` and `description` opportunistically.
47///
48/// # Errors
49///
50/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
51pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
52    conn.execute(
53        "INSERT INTO entities (namespace, name, type, description)
54         VALUES (?1, ?2, ?3, ?4)
55         ON CONFLICT(namespace, name) DO UPDATE SET
56           type        = excluded.type,
57           description = COALESCE(excluded.description, entities.description),
58           updated_at  = unixepoch()",
59        params![namespace, e.name, e.entity_type, e.description],
60    )?;
61    let id: i64 = conn.query_row(
62        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
63        params![namespace, e.name],
64        |r| r.get(0),
65    )?;
66    Ok(id)
67}
68
69/// Replaces the vector row for an entity in `vec_entities`.
70///
71/// vec0 virtual tables do not honour `INSERT OR REPLACE` when the primary key
72/// already exists — they raise a UNIQUE constraint error instead of silently
73/// replacing the row. The workaround is an explicit DELETE before INSERT so
74/// that the insert never conflicts. `embedding` must have length
75/// [`crate::constants::EMBEDDING_DIM`].
76///
77/// # Errors
78///
79/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
80pub fn upsert_entity_vec(
81    conn: &Connection,
82    entity_id: i64,
83    namespace: &str,
84    entity_type: EntityType,
85    embedding: &[f32],
86    name: &str,
87) -> Result<(), AppError> {
88    // Both statements wrapped in with_busy_retry: WAL concurrency can cause
89    // SQLITE_BUSY on vec0 virtual table writes when multiple CLI instances run.
90    let embedding_bytes = f32_to_bytes(embedding);
91    with_busy_retry(|| {
92        conn.execute(
93            "DELETE FROM vec_entities WHERE entity_id = ?1",
94            params![entity_id],
95        )?;
96        conn.execute(
97            "INSERT INTO vec_entities(entity_id, namespace, type, embedding, name)
98             VALUES (?1, ?2, ?3, ?4, ?5)",
99            params![entity_id, namespace, entity_type, &embedding_bytes, name],
100        )?;
101        Ok(())
102    })
103}
104
105/// Upserts a typed relationship between two entity ids.
106///
107/// Conflicts on `(source_id, target_id, relation)` refresh `weight` and
108/// preserve a non-null `description`. Returns the `rowid` of the stored row.
109///
110/// # Errors
111///
112/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
113pub fn upsert_relationship(
114    conn: &Connection,
115    namespace: &str,
116    source_id: i64,
117    target_id: i64,
118    rel: &NewRelationship,
119) -> Result<i64, AppError> {
120    conn.execute(
121        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
122         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
123         ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
124           weight = excluded.weight,
125           description = COALESCE(excluded.description, relationships.description)",
126        params![
127            namespace,
128            source_id,
129            target_id,
130            rel.relation,
131            rel.strength,
132            rel.description
133        ],
134    )?;
135    let id: i64 = conn.query_row(
136        "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
137        params![source_id, target_id, rel.relation],
138        |r| r.get(0),
139    )?;
140    Ok(id)
141}
142
143pub fn link_memory_entity(
144    conn: &Connection,
145    memory_id: i64,
146    entity_id: i64,
147) -> Result<(), AppError> {
148    conn.execute(
149        "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
150        params![memory_id, entity_id],
151    )?;
152    Ok(())
153}
154
155pub fn link_memory_relationship(
156    conn: &Connection,
157    memory_id: i64,
158    rel_id: i64,
159) -> Result<(), AppError> {
160    conn.execute(
161        "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
162        params![memory_id, rel_id],
163    )?;
164    Ok(())
165}
166
167pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
168    conn.execute(
169        "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
170        params![entity_id],
171    )?;
172    Ok(())
173}
174
175/// Looks up the entity by name and namespace. Returns the id when it exists.
176pub fn find_entity_id(
177    conn: &Connection,
178    namespace: &str,
179    name: &str,
180) -> Result<Option<i64>, AppError> {
181    let mut stmt =
182        conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
183    match stmt.query_row(params![namespace, name], |r| r.get::<_, i64>(0)) {
184        Ok(id) => Ok(Some(id)),
185        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
186        Err(e) => Err(AppError::Database(e)),
187    }
188}
189
190/// Structure representing an existing relation.
191#[derive(Debug, Serialize)]
192pub struct RelationshipRow {
193    pub id: i64,
194    pub namespace: String,
195    pub source_id: i64,
196    pub target_id: i64,
197    pub relation: String,
198    pub weight: f64,
199    pub description: Option<String>,
200}
201
202/// Looks up a specific relation by (source_id, target_id, relation).
203pub fn find_relationship(
204    conn: &Connection,
205    source_id: i64,
206    target_id: i64,
207    relation: &str,
208) -> Result<Option<RelationshipRow>, AppError> {
209    let mut stmt = conn.prepare_cached(
210        "SELECT id, namespace, source_id, target_id, relation, weight, description
211         FROM relationships
212         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
213    )?;
214    match stmt.query_row(params![source_id, target_id, relation], |r| {
215        Ok(RelationshipRow {
216            id: r.get(0)?,
217            namespace: r.get(1)?,
218            source_id: r.get(2)?,
219            target_id: r.get(3)?,
220            relation: r.get(4)?,
221            weight: r.get(5)?,
222            description: r.get(6)?,
223        })
224    }) {
225        Ok(row) => Ok(Some(row)),
226        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
227        Err(e) => Err(AppError::Database(e)),
228    }
229}
230
231/// Creates a relation if it does not exist (returns action="created")
232/// or returns the existing relation (action="already_exists") with updated weight.
233pub fn create_or_fetch_relationship(
234    conn: &Connection,
235    namespace: &str,
236    source_id: i64,
237    target_id: i64,
238    relation: &str,
239    weight: f64,
240    description: Option<&str>,
241) -> Result<(i64, bool), AppError> {
242    // Check if it exists first.
243    let existing = find_relationship(conn, source_id, target_id, relation)?;
244    if let Some(row) = existing {
245        return Ok((row.id, false));
246    }
247    conn.execute(
248        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
249         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
250        params![
251            namespace,
252            source_id,
253            target_id,
254            relation,
255            weight,
256            description
257        ],
258    )?;
259    let id: i64 = conn.query_row(
260        "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
261        params![source_id, target_id, relation],
262        |r| r.get(0),
263    )?;
264    Ok((id, true))
265}
266
267/// Removes a relation by id and cleans up memory_relationships.
268pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
269    conn.execute(
270        "DELETE FROM memory_relationships WHERE relationship_id = ?1",
271        params![relationship_id],
272    )?;
273    conn.execute(
274        "DELETE FROM relationships WHERE id = ?1",
275        params![relationship_id],
276    )?;
277    Ok(())
278}
279
280/// Recalculates the `degree` field of an entity.
281pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
282    conn.execute(
283        "UPDATE entities
284         SET degree = (SELECT COUNT(*) FROM relationships
285                       WHERE source_id = entities.id OR target_id = entities.id)
286         WHERE id = ?1",
287        params![entity_id],
288    )?;
289    Ok(())
290}
291
292/// Entity row with enough data for graph export/query.
293#[derive(Debug, Serialize, Clone)]
294pub struct EntityNode {
295    pub id: i64,
296    pub name: String,
297    pub namespace: String,
298    pub kind: String,
299}
300
301/// Lists entities, filtering by namespace if provided.
302pub fn list_entities(
303    conn: &Connection,
304    namespace: Option<&str>,
305) -> Result<Vec<EntityNode>, AppError> {
306    if let Some(ns) = namespace {
307        let mut stmt = conn.prepare(
308            "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
309        )?;
310        let rows = stmt
311            .query_map(params![ns], |r| {
312                Ok(EntityNode {
313                    id: r.get(0)?,
314                    name: r.get(1)?,
315                    namespace: r.get(2)?,
316                    kind: r.get(3)?,
317                })
318            })?
319            .collect::<Result<Vec<_>, _>>()?;
320        Ok(rows)
321    } else {
322        let mut stmt =
323            conn.prepare("SELECT id, name, namespace, type FROM entities ORDER BY namespace, id")?;
324        let rows = stmt
325            .query_map([], |r| {
326                Ok(EntityNode {
327                    id: r.get(0)?,
328                    name: r.get(1)?,
329                    namespace: r.get(2)?,
330                    kind: r.get(3)?,
331                })
332            })?
333            .collect::<Result<Vec<_>, _>>()?;
334        Ok(rows)
335    }
336}
337
338/// Lists relations filtered by namespace (of source/target entities).
339pub fn list_relationships_by_namespace(
340    conn: &Connection,
341    namespace: Option<&str>,
342) -> Result<Vec<RelationshipRow>, AppError> {
343    if let Some(ns) = namespace {
344        let mut stmt = conn.prepare(
345            "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
346             FROM relationships r
347             JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
348             JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
349             ORDER BY r.id",
350        )?;
351        let rows = stmt
352            .query_map(params![ns], |r| {
353                Ok(RelationshipRow {
354                    id: r.get(0)?,
355                    namespace: r.get(1)?,
356                    source_id: r.get(2)?,
357                    target_id: r.get(3)?,
358                    relation: r.get(4)?,
359                    weight: r.get(5)?,
360                    description: r.get(6)?,
361                })
362            })?
363            .collect::<Result<Vec<_>, _>>()?;
364        Ok(rows)
365    } else {
366        let mut stmt = conn.prepare(
367            "SELECT id, namespace, source_id, target_id, relation, weight, description
368             FROM relationships ORDER BY id",
369        )?;
370        let rows = stmt
371            .query_map([], |r| {
372                Ok(RelationshipRow {
373                    id: r.get(0)?,
374                    namespace: r.get(1)?,
375                    source_id: r.get(2)?,
376                    target_id: r.get(3)?,
377                    relation: r.get(4)?,
378                    weight: r.get(5)?,
379                    description: r.get(6)?,
380                })
381            })?
382            .collect::<Result<Vec<_>, _>>()?;
383        Ok(rows)
384    }
385}
386
387/// Locates orphan entities: no link in memory_entities and no relations.
388pub fn find_orphan_entity_ids(
389    conn: &Connection,
390    namespace: Option<&str>,
391) -> Result<Vec<i64>, AppError> {
392    if let Some(ns) = namespace {
393        let mut stmt = conn.prepare(
394            "SELECT e.id FROM entities e
395             WHERE e.namespace = ?1
396               AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
397               AND NOT EXISTS (
398                   SELECT 1 FROM relationships r
399                   WHERE r.source_id = e.id OR r.target_id = e.id
400               )",
401        )?;
402        let ids = stmt
403            .query_map(params![ns], |r| r.get::<_, i64>(0))?
404            .collect::<Result<Vec<_>, _>>()?;
405        Ok(ids)
406    } else {
407        let mut stmt = conn.prepare(
408            "SELECT e.id FROM entities e
409             WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
410               AND NOT EXISTS (
411                   SELECT 1 FROM relationships r
412                   WHERE r.source_id = e.id OR r.target_id = e.id
413               )",
414        )?;
415        let ids = stmt
416            .query_map([], |r| r.get::<_, i64>(0))?
417            .collect::<Result<Vec<_>, _>>()?;
418        Ok(ids)
419    }
420}
421
422/// Deletes entities and their associated vectors. Returns the number of entities removed.
423pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
424    if entity_ids.is_empty() {
425        return Ok(0);
426    }
427    let mut removed = 0usize;
428    for id in entity_ids {
429        // vec0 lacks FK CASCADE — clean vec_entities explicitly.
430        let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
431        let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
432        removed += affected;
433    }
434    Ok(removed)
435}
436
437pub fn knn_search(
438    conn: &Connection,
439    embedding: &[f32],
440    namespace: &str,
441    k: usize,
442) -> Result<Vec<(i64, f32)>, AppError> {
443    let bytes = f32_to_bytes(embedding);
444    let mut stmt = conn.prepare(
445        "SELECT entity_id, distance FROM vec_entities
446         WHERE embedding MATCH ?1 AND namespace = ?2
447         ORDER BY distance LIMIT ?3",
448    )?;
449    let rows = stmt
450        .query_map(params![bytes, namespace, k as i64], |r| {
451            Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
452        })?
453        .collect::<Result<Vec<_>, _>>()?;
454    Ok(rows)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::constants::EMBEDDING_DIM;
461    use crate::entity_type::EntityType;
462    use crate::storage::connection::register_vec_extension;
463    use rusqlite::Connection;
464    use tempfile::TempDir;
465
466    type TestResult = Result<(), Box<dyn std::error::Error>>;
467
468    fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
469        register_vec_extension();
470        let tmp = TempDir::new()?;
471        let db_path = tmp.path().join("test.db");
472        let mut conn = Connection::open(&db_path)?;
473        crate::migrations::runner().run(&mut conn)?;
474        Ok((tmp, conn))
475    }
476
477    fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
478        conn.execute(
479            "INSERT INTO memories (namespace, name, type, description, body, body_hash)
480             VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
481            [],
482        )?;
483        Ok(conn.last_insert_rowid())
484    }
485
486    fn new_entity_helper(name: &str) -> NewEntity {
487        NewEntity {
488            name: name.to_string(),
489            entity_type: EntityType::Project,
490            description: None,
491        }
492    }
493
494    fn embedding_zero() -> Vec<f32> {
495        vec![0.0f32; EMBEDDING_DIM]
496    }
497
498    // ------------------------------------------------------------------ //
499    // upsert_entity
500    // ------------------------------------------------------------------ //
501
502    #[test]
503    fn test_upsert_entity_creates_new() -> TestResult {
504        let (_tmp, conn) = setup_db()?;
505        let e = new_entity_helper("projeto-alpha");
506        let id = upsert_entity(&conn, "global", &e)?;
507        assert!(id > 0);
508        Ok(())
509    }
510
511    #[test]
512    fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
513        let (_tmp, conn) = setup_db()?;
514        let e = new_entity_helper("projeto-beta");
515        let id1 = upsert_entity(&conn, "global", &e)?;
516        let id2 = upsert_entity(&conn, "global", &e)?;
517        assert_eq!(id1, id2);
518        Ok(())
519    }
520
521    #[test]
522    fn test_upsert_entity_updates_description() -> TestResult {
523        let (_tmp, conn) = setup_db()?;
524        let e1 = new_entity_helper("projeto-gamma");
525        let id1 = upsert_entity(&conn, "global", &e1)?;
526
527        let e2 = NewEntity {
528            name: "projeto-gamma".to_string(),
529            entity_type: EntityType::Tool,
530            description: Some("nova desc".to_string()),
531        };
532        let id2 = upsert_entity(&conn, "global", &e2)?;
533        assert_eq!(id1, id2);
534
535        let desc: Option<String> = conn.query_row(
536            "SELECT description FROM entities WHERE id = ?1",
537            params![id1],
538            |r| r.get(0),
539        )?;
540        assert_eq!(desc.as_deref(), Some("nova desc"));
541        Ok(())
542    }
543
544    #[test]
545    fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
546        let (_tmp, conn) = setup_db()?;
547        let e = new_entity_helper("compartilhada");
548        let id1 = upsert_entity(&conn, "ns1", &e)?;
549        let id2 = upsert_entity(&conn, "ns2", &e)?;
550        assert_ne!(id1, id2);
551        Ok(())
552    }
553
554    // ------------------------------------------------------------------ //
555    // upsert_entity_vec — covers DELETE+INSERT (new branch after the OOM fix)
556    // ------------------------------------------------------------------ //
557
558    #[test]
559    fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
560        let (_tmp, conn) = setup_db()?;
561        let e = new_entity_helper("vec-nova");
562        let entity_id = upsert_entity(&conn, "global", &e)?;
563        let emb = embedding_zero();
564
565        let result = upsert_entity_vec(
566            &conn,
567            entity_id,
568            "global",
569            EntityType::Project,
570            &emb,
571            "vec-nova",
572        );
573        assert!(result.is_ok(), "first insertion must succeed");
574
575        let count: i64 = conn.query_row(
576            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
577            params![entity_id],
578            |r| r.get(0),
579        )?;
580        assert_eq!(count, 1, "must have exactly one row after insertion");
581        Ok(())
582    }
583
584    #[test]
585    fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
586        // Covers the branch where DELETE removes the existing row before INSERT.
587        let (_tmp, conn) = setup_db()?;
588        let e = new_entity_helper("vec-existente");
589        let entity_id = upsert_entity(&conn, "global", &e)?;
590        let emb = embedding_zero();
591
592        upsert_entity_vec(
593            &conn,
594            entity_id,
595            "global",
596            EntityType::Project,
597            &emb,
598            "vec-existente",
599        )?;
600
601        // Second call: DELETE returns 1 removed row, INSERT must succeed.
602        let result = upsert_entity_vec(
603            &conn,
604            entity_id,
605            "global",
606            EntityType::Tool,
607            &emb,
608            "vec-existente",
609        );
610        assert!(
611            result.is_ok(),
612            "second insertion (replace) must succeed: {result:?}"
613        );
614
615        let count: i64 = conn.query_row(
616            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
617            params![entity_id],
618            |r| r.get(0),
619        )?;
620        assert_eq!(count, 1, "must have exactly one row after replacement");
621        Ok(())
622    }
623
624    #[test]
625    fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
626        let (_tmp, conn) = setup_db()?;
627        let emb = embedding_zero();
628
629        for i in 0..3i64 {
630            let nome = format!("ent-{i}");
631            let e = new_entity_helper(&nome);
632            let entity_id = upsert_entity(&conn, "global", &e)?;
633            upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
634        }
635
636        let count: i64 = conn.query_row("SELECT COUNT(*) FROM vec_entities", [], |r| r.get(0))?;
637        assert_eq!(count, 3, "must have three distinct rows in vec_entities");
638        Ok(())
639    }
640
641    // ------------------------------------------------------------------ //
642    // find_entity_id
643    // ------------------------------------------------------------------ //
644
645    #[test]
646    fn test_find_entity_id_existing_returns_some() -> TestResult {
647        let (_tmp, conn) = setup_db()?;
648        let e = new_entity_helper("entidade-busca");
649        let id_inserido = upsert_entity(&conn, "global", &e)?;
650        let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
651        assert_eq!(id_encontrado, Some(id_inserido));
652        Ok(())
653    }
654
655    #[test]
656    fn test_find_entity_id_missing_returns_none() -> TestResult {
657        let (_tmp, conn) = setup_db()?;
658        let id = find_entity_id(&conn, "global", "nao-existe")?;
659        assert_eq!(id, None);
660        Ok(())
661    }
662
663    // ------------------------------------------------------------------ //
664    // delete_entities_by_ids
665    // ------------------------------------------------------------------ //
666
667    #[test]
668    fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
669        let (_tmp, conn) = setup_db()?;
670        let removed = delete_entities_by_ids(&conn, &[])?;
671        assert_eq!(removed, 0);
672        Ok(())
673    }
674
675    #[test]
676    fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
677        let (_tmp, conn) = setup_db()?;
678        let e = new_entity_helper("to-delete");
679        let entity_id = upsert_entity(&conn, "global", &e)?;
680
681        let removed = delete_entities_by_ids(&conn, &[entity_id])?;
682        assert_eq!(removed, 1);
683
684        let id = find_entity_id(&conn, "global", "to-delete")?;
685        assert_eq!(id, None, "entity must have been removed");
686        Ok(())
687    }
688
689    #[test]
690    fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
691        let (_tmp, conn) = setup_db()?;
692        let removed = delete_entities_by_ids(&conn, &[9999])?;
693        assert_eq!(removed, 0);
694        Ok(())
695    }
696
697    #[test]
698    fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
699        let (_tmp, conn) = setup_db()?;
700        let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
701        let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
702        let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
703
704        let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
705        assert_eq!(removed, 2);
706
707        assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
708        assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
709        assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
710        let _ = id3;
711        Ok(())
712    }
713
714    #[test]
715    fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
716        let (_tmp, conn) = setup_db()?;
717        let e = new_entity_helper("del-com-vec");
718        let entity_id = upsert_entity(&conn, "global", &e)?;
719        let emb = embedding_zero();
720        upsert_entity_vec(
721            &conn,
722            entity_id,
723            "global",
724            EntityType::Project,
725            &emb,
726            "del-com-vec",
727        )?;
728
729        let count_antes: i64 = conn.query_row(
730            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
731            params![entity_id],
732            |r| r.get(0),
733        )?;
734        assert_eq!(count_antes, 1);
735
736        delete_entities_by_ids(&conn, &[entity_id])?;
737
738        let count_depois: i64 = conn.query_row(
739            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
740            params![entity_id],
741            |r| r.get(0),
742        )?;
743        assert_eq!(
744            count_depois, 0,
745            "vec_entities deve ser limpo junto com entities"
746        );
747        Ok(())
748    }
749
750    // ------------------------------------------------------------------ //
751    // upsert_relationship / find_relationship
752    // ------------------------------------------------------------------ //
753
754    #[test]
755    fn test_upsert_relationship_creates_new() -> TestResult {
756        let (_tmp, conn) = setup_db()?;
757        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
758        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
759
760        let rel = NewRelationship {
761            source: "rel-a".to_string(),
762            target: "rel-b".to_string(),
763            relation: "uses".to_string(),
764            strength: 0.8,
765            description: None,
766        };
767        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
768        assert!(rel_id > 0);
769        Ok(())
770    }
771
772    #[test]
773    fn test_upsert_relationship_idempotent() -> TestResult {
774        let (_tmp, conn) = setup_db()?;
775        let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
776        let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
777
778        let rel = NewRelationship {
779            source: "idem-a".to_string(),
780            target: "idem-b".to_string(),
781            relation: "uses".to_string(),
782            strength: 0.5,
783            description: None,
784        };
785        let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
786        let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
787        assert_eq!(id1, id2);
788        Ok(())
789    }
790
791    #[test]
792    fn test_find_relationship_existing() -> TestResult {
793        let (_tmp, conn) = setup_db()?;
794        let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
795        let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
796
797        let rel = NewRelationship {
798            source: "fr-a".to_string(),
799            target: "fr-b".to_string(),
800            relation: "depends_on".to_string(),
801            strength: 0.7,
802            description: None,
803        };
804        upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
805
806        let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
807        let row = encontrada.ok_or("relationship should exist")?;
808        assert_eq!(row.source_id, id_a);
809        assert_eq!(row.target_id, id_b);
810        assert!((row.weight - 0.7).abs() < 1e-9);
811        Ok(())
812    }
813
814    #[test]
815    fn test_find_relationship_missing_returns_none() -> TestResult {
816        let (_tmp, conn) = setup_db()?;
817        let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
818        assert!(resultado.is_none());
819        Ok(())
820    }
821
822    // ------------------------------------------------------------------ //
823    // link_memory_entity / link_memory_relationship
824    // ------------------------------------------------------------------ //
825
826    #[test]
827    fn test_link_memory_entity_idempotent() -> TestResult {
828        let (_tmp, conn) = setup_db()?;
829        let memory_id = insert_memory(&conn)?;
830        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
831
832        link_memory_entity(&conn, memory_id, entity_id)?;
833        let resultado = link_memory_entity(&conn, memory_id, entity_id);
834        assert!(
835            resultado.is_ok(),
836            "INSERT OR IGNORE must not fail on duplicate"
837        );
838        Ok(())
839    }
840
841    #[test]
842    fn test_link_memory_relationship_idempotent() -> TestResult {
843        let (_tmp, conn) = setup_db()?;
844        let memory_id = insert_memory(&conn)?;
845        let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
846        let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
847
848        let rel = NewRelationship {
849            source: "mr-a".to_string(),
850            target: "mr-b".to_string(),
851            relation: "uses".to_string(),
852            strength: 0.5,
853            description: None,
854        };
855        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
856
857        link_memory_relationship(&conn, memory_id, rel_id)?;
858        let resultado = link_memory_relationship(&conn, memory_id, rel_id);
859        assert!(
860            resultado.is_ok(),
861            "INSERT OR IGNORE must not fail on duplicate"
862        );
863        Ok(())
864    }
865
866    // ------------------------------------------------------------------ //
867    // increment_degree / recalculate_degree
868    // ------------------------------------------------------------------ //
869
870    #[test]
871    fn test_increment_degree_increases_counter() -> TestResult {
872        let (_tmp, conn) = setup_db()?;
873        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
874
875        increment_degree(&conn, entity_id)?;
876        increment_degree(&conn, entity_id)?;
877
878        let degree: i64 = conn.query_row(
879            "SELECT degree FROM entities WHERE id = ?1",
880            params![entity_id],
881            |r| r.get(0),
882        )?;
883        assert_eq!(degree, 2);
884        Ok(())
885    }
886
887    #[test]
888    fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
889        let (_tmp, conn) = setup_db()?;
890        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
891        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
892        let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
893
894        let rel1 = NewRelationship {
895            source: "rc-a".to_string(),
896            target: "rc-b".to_string(),
897            relation: "uses".to_string(),
898            strength: 0.5,
899            description: None,
900        };
901        let rel2 = NewRelationship {
902            source: "rc-c".to_string(),
903            target: "rc-a".to_string(),
904            relation: "depends_on".to_string(),
905            strength: 0.5,
906            description: None,
907        };
908        upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
909        upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
910
911        recalculate_degree(&conn, id_a)?;
912
913        let degree: i64 = conn.query_row(
914            "SELECT degree FROM entities WHERE id = ?1",
915            params![id_a],
916            |r| r.get(0),
917        )?;
918        assert_eq!(
919            degree, 2,
920            "rc-a appears in two relationships (source+target)"
921        );
922        Ok(())
923    }
924
925    // ------------------------------------------------------------------ //
926    // find_orphan_entity_ids
927    // ------------------------------------------------------------------ //
928
929    #[test]
930    fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
931        let (_tmp, conn) = setup_db()?;
932        let memory_id = insert_memory(&conn)?;
933        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
934        link_memory_entity(&conn, memory_id, entity_id)?;
935
936        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
937        assert!(!orfas.contains(&entity_id));
938        Ok(())
939    }
940
941    #[test]
942    fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
943        let (_tmp, conn) = setup_db()?;
944        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
945
946        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
947        assert!(orfas.contains(&entity_id));
948        Ok(())
949    }
950
951    #[test]
952    fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
953        let (_tmp, conn) = setup_db()?;
954        let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
955        let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
956
957        let orfas = find_orphan_entity_ids(&conn, None)?;
958        assert!(orfas.contains(&id1));
959        assert!(orfas.contains(&id2));
960        Ok(())
961    }
962
963    // ------------------------------------------------------------------ //
964    // list_entities / list_relationships_by_namespace
965    // ------------------------------------------------------------------ //
966
967    #[test]
968    fn test_list_entities_with_namespace() -> TestResult {
969        let (_tmp, conn) = setup_db()?;
970        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
971        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
972        upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
973
974        let lista = list_entities(&conn, Some("le-ns"))?;
975        assert_eq!(lista.len(), 2);
976        assert!(lista.iter().all(|e| e.namespace == "le-ns"));
977        Ok(())
978    }
979
980    #[test]
981    fn test_list_entities_without_namespace_returns_all() -> TestResult {
982        let (_tmp, conn) = setup_db()?;
983        upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
984        upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
985
986        let lista = list_entities(&conn, None)?;
987        assert!(lista.len() >= 2);
988        Ok(())
989    }
990
991    #[test]
992    fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
993        let (_tmp, conn) = setup_db()?;
994        let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
995        let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
996
997        let rel = NewRelationship {
998            source: "lr-a".to_string(),
999            target: "lr-b".to_string(),
1000            relation: "uses".to_string(),
1001            strength: 0.5,
1002            description: None,
1003        };
1004        upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1005
1006        let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1007        assert!(!lista.is_empty());
1008        assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1009        Ok(())
1010    }
1011
1012    // ------------------------------------------------------------------ //
1013    // delete_relationship_by_id / create_or_fetch_relationship
1014    // ------------------------------------------------------------------ //
1015
1016    #[test]
1017    fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1018        let (_tmp, conn) = setup_db()?;
1019        let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1020        let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1021
1022        let rel = NewRelationship {
1023            source: "dr-a".to_string(),
1024            target: "dr-b".to_string(),
1025            relation: "uses".to_string(),
1026            strength: 0.5,
1027            description: None,
1028        };
1029        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1030
1031        delete_relationship_by_id(&conn, rel_id)?;
1032
1033        let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1034        assert!(encontrada.is_none(), "relationship must have been removed");
1035        Ok(())
1036    }
1037
1038    #[test]
1039    fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1040        let (_tmp, conn) = setup_db()?;
1041        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1042        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1043
1044        let (rel_id, created) =
1045            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1046        assert!(rel_id > 0);
1047        assert!(created);
1048        Ok(())
1049    }
1050
1051    #[test]
1052    fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1053        let (_tmp, conn) = setup_db()?;
1054        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1055        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1056
1057        create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1058        let (_, created) =
1059            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1060        assert!(
1061            !created,
1062            "second call must return the existing relationship"
1063        );
1064        Ok(())
1065    }
1066
1067    // ------------------------------------------------------------------ //
1068    // serde alias: field "type" accepted as a synonym for "entity_type"
1069    // ------------------------------------------------------------------ //
1070
1071    #[test]
1072    fn accepts_type_field_as_alias() -> TestResult {
1073        let json = r#"{"name": "X", "type": "concept"}"#;
1074        let ent: NewEntity = serde_json::from_str(json)?;
1075        assert_eq!(ent.entity_type, EntityType::Concept);
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn accepts_canonical_entity_type_field() -> TestResult {
1081        let json = r#"{"name": "X", "entity_type": "concept"}"#;
1082        let ent: NewEntity = serde_json::from_str(json)?;
1083        assert_eq!(ent.entity_type, EntityType::Concept);
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn both_fields_present_yields_duplicate_error() {
1089        // having both entity_type and type in the same JSON is a duplicate and must fail
1090        let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1091        let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1092        assert!(
1093            resultado.is_err(),
1094            "both fields in the same JSON are a duplicate"
1095        );
1096    }
1097}