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