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
437/// Counts relationships matching the given relation type within a namespace.
438///
439/// Used by `prune-relations --dry-run` to preview the number of relationships
440/// that would be deleted without actually modifying the database.
441///
442/// # Errors
443///
444/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
445pub fn count_relationships_by_relation(
446    conn: &Connection,
447    namespace: &str,
448    relation: &str,
449) -> Result<usize, AppError> {
450    let count: i64 = conn.query_row(
451        "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
452        params![namespace, relation],
453        |r| r.get(0),
454    )?;
455    Ok(count as usize)
456}
457
458/// Deletes all relationships matching a relation type within a namespace.
459///
460/// Operates in chunks of 1000 to avoid holding long write locks and blocking
461/// WAL readers. After deletion, recalculates degree for every affected entity.
462///
463/// Returns `(count_deleted, affected_entity_ids)`.
464///
465/// # Errors
466///
467/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
468pub fn delete_relationships_by_relation(
469    conn: &Connection,
470    namespace: &str,
471    relation: &str,
472) -> Result<(usize, Vec<i64>), AppError> {
473    // Step 1: collect all affected entity IDs before deletion.
474    let mut stmt = conn.prepare(
475        "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
476         UNION
477         SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
478    )?;
479    let entity_ids: Vec<i64> = stmt
480        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
481        .collect::<Result<Vec<_>, _>>()?;
482
483    // Step 2: collect relationship IDs to delete.
484    let mut id_stmt =
485        conn.prepare("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
486    let rel_ids: Vec<i64> = id_stmt
487        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
488        .collect::<Result<Vec<_>, _>>()?;
489
490    // Step 3: delete in chunks of 1000 (memory_relationships + relationships).
491    let mut total_deleted: usize = 0;
492    for chunk in rel_ids.chunks(1000) {
493        for &rel_id in chunk {
494            conn.execute(
495                "DELETE FROM memory_relationships WHERE relationship_id = ?1",
496                params![rel_id],
497            )?;
498            let affected =
499                conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
500            total_deleted += affected;
501        }
502    }
503
504    // Step 4: recalculate degree for all affected entities.
505    for &eid in &entity_ids {
506        recalculate_degree(conn, eid)?;
507    }
508
509    Ok((total_deleted, entity_ids))
510}
511
512pub fn knn_search(
513    conn: &Connection,
514    embedding: &[f32],
515    namespace: &str,
516    k: usize,
517) -> Result<Vec<(i64, f32)>, AppError> {
518    let bytes = f32_to_bytes(embedding);
519    let mut stmt = conn.prepare(
520        "SELECT entity_id, distance FROM vec_entities
521         WHERE embedding MATCH ?1 AND namespace = ?2
522         ORDER BY distance LIMIT ?3",
523    )?;
524    let rows = stmt
525        .query_map(params![bytes, namespace, k as i64], |r| {
526            Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
527        })?
528        .collect::<Result<Vec<_>, _>>()?;
529    Ok(rows)
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::constants::EMBEDDING_DIM;
536    use crate::entity_type::EntityType;
537    use crate::storage::connection::register_vec_extension;
538    use rusqlite::Connection;
539    use tempfile::TempDir;
540
541    type TestResult = Result<(), Box<dyn std::error::Error>>;
542
543    fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
544        register_vec_extension();
545        let tmp = TempDir::new()?;
546        let db_path = tmp.path().join("test.db");
547        let mut conn = Connection::open(&db_path)?;
548        crate::migrations::runner().run(&mut conn)?;
549        Ok((tmp, conn))
550    }
551
552    fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
553        conn.execute(
554            "INSERT INTO memories (namespace, name, type, description, body, body_hash)
555             VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
556            [],
557        )?;
558        Ok(conn.last_insert_rowid())
559    }
560
561    fn new_entity_helper(name: &str) -> NewEntity {
562        NewEntity {
563            name: name.to_string(),
564            entity_type: EntityType::Project,
565            description: None,
566        }
567    }
568
569    fn embedding_zero() -> Vec<f32> {
570        vec![0.0f32; EMBEDDING_DIM]
571    }
572
573    // ------------------------------------------------------------------ //
574    // upsert_entity
575    // ------------------------------------------------------------------ //
576
577    #[test]
578    fn test_upsert_entity_creates_new() -> TestResult {
579        let (_tmp, conn) = setup_db()?;
580        let e = new_entity_helper("projeto-alpha");
581        let id = upsert_entity(&conn, "global", &e)?;
582        assert!(id > 0);
583        Ok(())
584    }
585
586    #[test]
587    fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
588        let (_tmp, conn) = setup_db()?;
589        let e = new_entity_helper("projeto-beta");
590        let id1 = upsert_entity(&conn, "global", &e)?;
591        let id2 = upsert_entity(&conn, "global", &e)?;
592        assert_eq!(id1, id2);
593        Ok(())
594    }
595
596    #[test]
597    fn test_upsert_entity_updates_description() -> TestResult {
598        let (_tmp, conn) = setup_db()?;
599        let e1 = new_entity_helper("projeto-gamma");
600        let id1 = upsert_entity(&conn, "global", &e1)?;
601
602        let e2 = NewEntity {
603            name: "projeto-gamma".to_string(),
604            entity_type: EntityType::Tool,
605            description: Some("nova desc".to_string()),
606        };
607        let id2 = upsert_entity(&conn, "global", &e2)?;
608        assert_eq!(id1, id2);
609
610        let desc: Option<String> = conn.query_row(
611            "SELECT description FROM entities WHERE id = ?1",
612            params![id1],
613            |r| r.get(0),
614        )?;
615        assert_eq!(desc.as_deref(), Some("nova desc"));
616        Ok(())
617    }
618
619    #[test]
620    fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
621        let (_tmp, conn) = setup_db()?;
622        let e = new_entity_helper("compartilhada");
623        let id1 = upsert_entity(&conn, "ns1", &e)?;
624        let id2 = upsert_entity(&conn, "ns2", &e)?;
625        assert_ne!(id1, id2);
626        Ok(())
627    }
628
629    // ------------------------------------------------------------------ //
630    // upsert_entity_vec — covers DELETE+INSERT (new branch after the OOM fix)
631    // ------------------------------------------------------------------ //
632
633    #[test]
634    fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
635        let (_tmp, conn) = setup_db()?;
636        let e = new_entity_helper("vec-nova");
637        let entity_id = upsert_entity(&conn, "global", &e)?;
638        let emb = embedding_zero();
639
640        let result = upsert_entity_vec(
641            &conn,
642            entity_id,
643            "global",
644            EntityType::Project,
645            &emb,
646            "vec-nova",
647        );
648        assert!(result.is_ok(), "first insertion must succeed");
649
650        let count: i64 = conn.query_row(
651            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
652            params![entity_id],
653            |r| r.get(0),
654        )?;
655        assert_eq!(count, 1, "must have exactly one row after insertion");
656        Ok(())
657    }
658
659    #[test]
660    fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
661        // Covers the branch where DELETE removes the existing row before INSERT.
662        let (_tmp, conn) = setup_db()?;
663        let e = new_entity_helper("vec-existente");
664        let entity_id = upsert_entity(&conn, "global", &e)?;
665        let emb = embedding_zero();
666
667        upsert_entity_vec(
668            &conn,
669            entity_id,
670            "global",
671            EntityType::Project,
672            &emb,
673            "vec-existente",
674        )?;
675
676        // Second call: DELETE returns 1 removed row, INSERT must succeed.
677        let result = upsert_entity_vec(
678            &conn,
679            entity_id,
680            "global",
681            EntityType::Tool,
682            &emb,
683            "vec-existente",
684        );
685        assert!(
686            result.is_ok(),
687            "second insertion (replace) must succeed: {result:?}"
688        );
689
690        let count: i64 = conn.query_row(
691            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
692            params![entity_id],
693            |r| r.get(0),
694        )?;
695        assert_eq!(count, 1, "must have exactly one row after replacement");
696        Ok(())
697    }
698
699    #[test]
700    fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
701        let (_tmp, conn) = setup_db()?;
702        let emb = embedding_zero();
703
704        for i in 0..3i64 {
705            let nome = format!("ent-{i}");
706            let e = new_entity_helper(&nome);
707            let entity_id = upsert_entity(&conn, "global", &e)?;
708            upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
709        }
710
711        let count: i64 = conn.query_row("SELECT COUNT(*) FROM vec_entities", [], |r| r.get(0))?;
712        assert_eq!(count, 3, "must have three distinct rows in vec_entities");
713        Ok(())
714    }
715
716    // ------------------------------------------------------------------ //
717    // find_entity_id
718    // ------------------------------------------------------------------ //
719
720    #[test]
721    fn test_find_entity_id_existing_returns_some() -> TestResult {
722        let (_tmp, conn) = setup_db()?;
723        let e = new_entity_helper("entidade-busca");
724        let id_inserido = upsert_entity(&conn, "global", &e)?;
725        let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
726        assert_eq!(id_encontrado, Some(id_inserido));
727        Ok(())
728    }
729
730    #[test]
731    fn test_find_entity_id_missing_returns_none() -> TestResult {
732        let (_tmp, conn) = setup_db()?;
733        let id = find_entity_id(&conn, "global", "nao-existe")?;
734        assert_eq!(id, None);
735        Ok(())
736    }
737
738    // ------------------------------------------------------------------ //
739    // delete_entities_by_ids
740    // ------------------------------------------------------------------ //
741
742    #[test]
743    fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
744        let (_tmp, conn) = setup_db()?;
745        let removed = delete_entities_by_ids(&conn, &[])?;
746        assert_eq!(removed, 0);
747        Ok(())
748    }
749
750    #[test]
751    fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
752        let (_tmp, conn) = setup_db()?;
753        let e = new_entity_helper("to-delete");
754        let entity_id = upsert_entity(&conn, "global", &e)?;
755
756        let removed = delete_entities_by_ids(&conn, &[entity_id])?;
757        assert_eq!(removed, 1);
758
759        let id = find_entity_id(&conn, "global", "to-delete")?;
760        assert_eq!(id, None, "entity must have been removed");
761        Ok(())
762    }
763
764    #[test]
765    fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
766        let (_tmp, conn) = setup_db()?;
767        let removed = delete_entities_by_ids(&conn, &[9999])?;
768        assert_eq!(removed, 0);
769        Ok(())
770    }
771
772    #[test]
773    fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
774        let (_tmp, conn) = setup_db()?;
775        let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
776        let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
777        let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
778
779        let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
780        assert_eq!(removed, 2);
781
782        assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
783        assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
784        assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
785        let _ = id3;
786        Ok(())
787    }
788
789    #[test]
790    fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
791        let (_tmp, conn) = setup_db()?;
792        let e = new_entity_helper("del-com-vec");
793        let entity_id = upsert_entity(&conn, "global", &e)?;
794        let emb = embedding_zero();
795        upsert_entity_vec(
796            &conn,
797            entity_id,
798            "global",
799            EntityType::Project,
800            &emb,
801            "del-com-vec",
802        )?;
803
804        let count_antes: i64 = conn.query_row(
805            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
806            params![entity_id],
807            |r| r.get(0),
808        )?;
809        assert_eq!(count_antes, 1);
810
811        delete_entities_by_ids(&conn, &[entity_id])?;
812
813        let count_depois: i64 = conn.query_row(
814            "SELECT COUNT(*) FROM vec_entities WHERE entity_id = ?1",
815            params![entity_id],
816            |r| r.get(0),
817        )?;
818        assert_eq!(
819            count_depois, 0,
820            "vec_entities deve ser limpo junto com entities"
821        );
822        Ok(())
823    }
824
825    // ------------------------------------------------------------------ //
826    // upsert_relationship / find_relationship
827    // ------------------------------------------------------------------ //
828
829    #[test]
830    fn test_upsert_relationship_creates_new() -> TestResult {
831        let (_tmp, conn) = setup_db()?;
832        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
833        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
834
835        let rel = NewRelationship {
836            source: "rel-a".to_string(),
837            target: "rel-b".to_string(),
838            relation: "uses".to_string(),
839            strength: 0.8,
840            description: None,
841        };
842        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
843        assert!(rel_id > 0);
844        Ok(())
845    }
846
847    #[test]
848    fn test_upsert_relationship_idempotent() -> TestResult {
849        let (_tmp, conn) = setup_db()?;
850        let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
851        let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
852
853        let rel = NewRelationship {
854            source: "idem-a".to_string(),
855            target: "idem-b".to_string(),
856            relation: "uses".to_string(),
857            strength: 0.5,
858            description: None,
859        };
860        let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
861        let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
862        assert_eq!(id1, id2);
863        Ok(())
864    }
865
866    #[test]
867    fn test_find_relationship_existing() -> TestResult {
868        let (_tmp, conn) = setup_db()?;
869        let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
870        let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
871
872        let rel = NewRelationship {
873            source: "fr-a".to_string(),
874            target: "fr-b".to_string(),
875            relation: "depends_on".to_string(),
876            strength: 0.7,
877            description: None,
878        };
879        upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
880
881        let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
882        let row = encontrada.ok_or("relationship should exist")?;
883        assert_eq!(row.source_id, id_a);
884        assert_eq!(row.target_id, id_b);
885        assert!((row.weight - 0.7).abs() < 1e-9);
886        Ok(())
887    }
888
889    #[test]
890    fn test_find_relationship_missing_returns_none() -> TestResult {
891        let (_tmp, conn) = setup_db()?;
892        let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
893        assert!(resultado.is_none());
894        Ok(())
895    }
896
897    // ------------------------------------------------------------------ //
898    // link_memory_entity / link_memory_relationship
899    // ------------------------------------------------------------------ //
900
901    #[test]
902    fn test_link_memory_entity_idempotent() -> TestResult {
903        let (_tmp, conn) = setup_db()?;
904        let memory_id = insert_memory(&conn)?;
905        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
906
907        link_memory_entity(&conn, memory_id, entity_id)?;
908        let resultado = link_memory_entity(&conn, memory_id, entity_id);
909        assert!(
910            resultado.is_ok(),
911            "INSERT OR IGNORE must not fail on duplicate"
912        );
913        Ok(())
914    }
915
916    #[test]
917    fn test_link_memory_relationship_idempotent() -> TestResult {
918        let (_tmp, conn) = setup_db()?;
919        let memory_id = insert_memory(&conn)?;
920        let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
921        let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
922
923        let rel = NewRelationship {
924            source: "mr-a".to_string(),
925            target: "mr-b".to_string(),
926            relation: "uses".to_string(),
927            strength: 0.5,
928            description: None,
929        };
930        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
931
932        link_memory_relationship(&conn, memory_id, rel_id)?;
933        let resultado = link_memory_relationship(&conn, memory_id, rel_id);
934        assert!(
935            resultado.is_ok(),
936            "INSERT OR IGNORE must not fail on duplicate"
937        );
938        Ok(())
939    }
940
941    // ------------------------------------------------------------------ //
942    // increment_degree / recalculate_degree
943    // ------------------------------------------------------------------ //
944
945    #[test]
946    fn test_increment_degree_increases_counter() -> TestResult {
947        let (_tmp, conn) = setup_db()?;
948        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
949
950        increment_degree(&conn, entity_id)?;
951        increment_degree(&conn, entity_id)?;
952
953        let degree: i64 = conn.query_row(
954            "SELECT degree FROM entities WHERE id = ?1",
955            params![entity_id],
956            |r| r.get(0),
957        )?;
958        assert_eq!(degree, 2);
959        Ok(())
960    }
961
962    #[test]
963    fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
964        let (_tmp, conn) = setup_db()?;
965        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
966        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
967        let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
968
969        let rel1 = NewRelationship {
970            source: "rc-a".to_string(),
971            target: "rc-b".to_string(),
972            relation: "uses".to_string(),
973            strength: 0.5,
974            description: None,
975        };
976        let rel2 = NewRelationship {
977            source: "rc-c".to_string(),
978            target: "rc-a".to_string(),
979            relation: "depends_on".to_string(),
980            strength: 0.5,
981            description: None,
982        };
983        upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
984        upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
985
986        recalculate_degree(&conn, id_a)?;
987
988        let degree: i64 = conn.query_row(
989            "SELECT degree FROM entities WHERE id = ?1",
990            params![id_a],
991            |r| r.get(0),
992        )?;
993        assert_eq!(
994            degree, 2,
995            "rc-a appears in two relationships (source+target)"
996        );
997        Ok(())
998    }
999
1000    // ------------------------------------------------------------------ //
1001    // find_orphan_entity_ids
1002    // ------------------------------------------------------------------ //
1003
1004    #[test]
1005    fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1006        let (_tmp, conn) = setup_db()?;
1007        let memory_id = insert_memory(&conn)?;
1008        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1009        link_memory_entity(&conn, memory_id, entity_id)?;
1010
1011        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1012        assert!(!orfas.contains(&entity_id));
1013        Ok(())
1014    }
1015
1016    #[test]
1017    fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1018        let (_tmp, conn) = setup_db()?;
1019        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1020
1021        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1022        assert!(orfas.contains(&entity_id));
1023        Ok(())
1024    }
1025
1026    #[test]
1027    fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1028        let (_tmp, conn) = setup_db()?;
1029        let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1030        let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1031
1032        let orfas = find_orphan_entity_ids(&conn, None)?;
1033        assert!(orfas.contains(&id1));
1034        assert!(orfas.contains(&id2));
1035        Ok(())
1036    }
1037
1038    // ------------------------------------------------------------------ //
1039    // list_entities / list_relationships_by_namespace
1040    // ------------------------------------------------------------------ //
1041
1042    #[test]
1043    fn test_list_entities_with_namespace() -> TestResult {
1044        let (_tmp, conn) = setup_db()?;
1045        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1046        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1047        upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1048
1049        let lista = list_entities(&conn, Some("le-ns"))?;
1050        assert_eq!(lista.len(), 2);
1051        assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1052        Ok(())
1053    }
1054
1055    #[test]
1056    fn test_list_entities_without_namespace_returns_all() -> TestResult {
1057        let (_tmp, conn) = setup_db()?;
1058        upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1059        upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1060
1061        let lista = list_entities(&conn, None)?;
1062        assert!(lista.len() >= 2);
1063        Ok(())
1064    }
1065
1066    #[test]
1067    fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1068        let (_tmp, conn) = setup_db()?;
1069        let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1070        let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1071
1072        let rel = NewRelationship {
1073            source: "lr-a".to_string(),
1074            target: "lr-b".to_string(),
1075            relation: "uses".to_string(),
1076            strength: 0.5,
1077            description: None,
1078        };
1079        upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1080
1081        let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1082        assert!(!lista.is_empty());
1083        assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1084        Ok(())
1085    }
1086
1087    // ------------------------------------------------------------------ //
1088    // delete_relationship_by_id / create_or_fetch_relationship
1089    // ------------------------------------------------------------------ //
1090
1091    #[test]
1092    fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1093        let (_tmp, conn) = setup_db()?;
1094        let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1095        let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1096
1097        let rel = NewRelationship {
1098            source: "dr-a".to_string(),
1099            target: "dr-b".to_string(),
1100            relation: "uses".to_string(),
1101            strength: 0.5,
1102            description: None,
1103        };
1104        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1105
1106        delete_relationship_by_id(&conn, rel_id)?;
1107
1108        let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1109        assert!(encontrada.is_none(), "relationship must have been removed");
1110        Ok(())
1111    }
1112
1113    #[test]
1114    fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1115        let (_tmp, conn) = setup_db()?;
1116        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1117        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1118
1119        let (rel_id, created) =
1120            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1121        assert!(rel_id > 0);
1122        assert!(created);
1123        Ok(())
1124    }
1125
1126    #[test]
1127    fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1128        let (_tmp, conn) = setup_db()?;
1129        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1130        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1131
1132        create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1133        let (_, created) =
1134            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1135        assert!(
1136            !created,
1137            "second call must return the existing relationship"
1138        );
1139        Ok(())
1140    }
1141
1142    // ------------------------------------------------------------------ //
1143    // serde alias: field "type" accepted as a synonym for "entity_type"
1144    // ------------------------------------------------------------------ //
1145
1146    #[test]
1147    fn accepts_type_field_as_alias() -> TestResult {
1148        let json = r#"{"name": "X", "type": "concept"}"#;
1149        let ent: NewEntity = serde_json::from_str(json)?;
1150        assert_eq!(ent.entity_type, EntityType::Concept);
1151        Ok(())
1152    }
1153
1154    #[test]
1155    fn accepts_canonical_entity_type_field() -> TestResult {
1156        let json = r#"{"name": "X", "entity_type": "concept"}"#;
1157        let ent: NewEntity = serde_json::from_str(json)?;
1158        assert_eq!(ent.entity_type, EntityType::Concept);
1159        Ok(())
1160    }
1161
1162    #[test]
1163    fn both_fields_present_yields_duplicate_error() {
1164        // having both entity_type and type in the same JSON is a duplicate and must fail
1165        let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1166        let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1167        assert!(
1168            resultado.is_err(),
1169            "both fields in the same JSON are a duplicate"
1170        );
1171    }
1172}