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