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::parsers::normalize_entity_name;
11use crate::storage::utils::with_busy_retry;
12use rusqlite::{params, Connection};
13use serde::{Deserialize, Serialize};
14
15/// Input payload used to upsert a single entity.
16///
17/// `name` is normalized to kebab-case by the caller. `description` is
18/// optional and preserved across upserts when the new value is `None`.
19#[derive(Debug, Serialize, Deserialize, Clone)]
20#[serde(deny_unknown_fields)]
21pub struct NewEntity {
22    pub name: String,
23    #[serde(alias = "type")]
24    pub entity_type: EntityType,
25    pub description: Option<String>,
26}
27
28/// Input payload used to upsert a typed relationship between entities.
29///
30/// `strength` must lie within `[0.0, 1.0]` and is mapped to the `weight`
31/// column of the `relationships` table.
32#[derive(Debug, Serialize, Deserialize, Clone)]
33#[serde(deny_unknown_fields)]
34pub struct NewRelationship {
35    #[serde(alias = "from")]
36    pub source: String,
37    #[serde(alias = "to")]
38    pub target: String,
39    #[serde(alias = "type")]
40    pub relation: String,
41    #[serde(alias = "weight")]
42    pub strength: f64,
43    pub description: Option<String>,
44}
45
46/// Validates entity name against quality rules.
47///
48/// Rejects names with newlines, names shorter than 2 characters, and
49/// ALL_CAPS abbreviations of 4 characters or fewer (common NER noise).
50///
51/// # Errors
52///
53/// Returns `Err(AppError::Validation)` when the name violates any rule.
54pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
55    if name.len() < 2 {
56        return Err(AppError::Validation(format!(
57            "entity name '{name}' must be at least 2 characters"
58        )));
59    }
60    if name.contains('\n') || name.contains('\r') {
61        return Err(AppError::Validation(
62            "entity name must not contain newline characters".to_string(),
63        ));
64    }
65    if name.len() <= 4
66        && name
67            .chars()
68            .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
69    {
70        return Err(AppError::Validation(format!(
71            "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
72        )));
73    }
74    Ok(())
75}
76
77/// Upserts an entity and returns its primary key.
78///
79/// Uses `ON CONFLICT(namespace, name)` to keep one row per entity within a
80/// namespace, refreshing `type` and `description` opportunistically.
81///
82/// # Errors
83///
84/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
85pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
86    // Step 1: validate the original name — catches ALL_CAPS short noise (NER artefacts),
87    // newlines, and names shorter than 2 characters before any transformation.
88    validate_entity_name(&e.name)?;
89    // Step 2: normalize to kebab-case ASCII (NFKD, lowercase, spaces/underscores → hyphens).
90    let normalized_name = normalize_entity_name(&e.name);
91    // Step 3: guard post-normalization length — a valid original could collapse to < 2 chars
92    // (e.g. a single accented character that strips entirely).
93    if normalized_name.chars().count() < 2 {
94        return Err(AppError::Validation(format!(
95            "entity name '{}' normalizes to '{}' which is too short (minimum 2 characters)",
96            e.name, normalized_name
97        )));
98    }
99    conn.execute(
100        "INSERT INTO entities (namespace, name, type, description)
101         VALUES (?1, ?2, ?3, ?4)
102         ON CONFLICT(namespace, name) DO UPDATE SET
103           type        = excluded.type,
104           description = COALESCE(excluded.description, entities.description),
105           updated_at  = unixepoch()",
106        params![namespace, normalized_name, e.entity_type, e.description],
107    )?;
108    let id: i64 = conn.query_row(
109        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
110        params![namespace, normalized_name],
111        |r| r.get(0),
112    )?;
113    Ok(id)
114}
115
116/// Replaces the vector row for an entity in `entity_embeddings`.
117///
118/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
119/// table; cosine similarity is computed in pure Rust on demand. The
120/// `entity_type` and `name` arguments are accepted for API compatibility
121/// but are not stored — the entities table is the source of truth.
122///
123/// # Errors
124///
125/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
126pub fn upsert_entity_vec(
127    conn: &Connection,
128    entity_id: i64,
129    namespace: &str,
130    _entity_type: EntityType,
131    embedding: &[f32],
132    _name: &str,
133) -> Result<(), AppError> {
134    // v1.1.1 (P1): an empty vector means the embedding backend was skipped
135    // (`--llm-backend none` without OpenRouter). Writing an empty BLOB would
136    // hide the entity from the re-embed backfill scanner (the row exists but
137    // carries no vector), so skip the write and leave the entity scannable.
138    if embedding.is_empty() {
139        tracing::debug!(
140            entity_id,
141            "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
142        );
143        return Ok(());
144    }
145    let embedding_bytes = f32_to_bytes(embedding);
146    with_busy_retry(|| {
147        conn.execute(
148            "DELETE FROM entity_embeddings WHERE entity_id = ?1",
149            params![entity_id],
150        )?;
151        conn.execute(
152            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
153             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
154            params![
155                entity_id,
156                namespace,
157                &embedding_bytes,
158                "llm-headless",
159                crate::constants::SQLITE_GRAPHRAG_VERSION,
160                crate::constants::embedding_dim() as i64,
161            ],
162        )?;
163        Ok(())
164    })
165}
166
167/// Upserts a typed relationship between two entity ids.
168///
169/// Conflicts on `(source_id, target_id, relation)` refresh `weight` and
170/// preserve a non-null `description`. Returns the `rowid` of the stored row.
171///
172/// # Errors
173///
174/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
175pub fn upsert_relationship(
176    conn: &Connection,
177    namespace: &str,
178    source_id: i64,
179    target_id: i64,
180    rel: &NewRelationship,
181) -> Result<i64, AppError> {
182    conn.execute(
183        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
184         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
185         ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
186           weight = excluded.weight,
187           description = COALESCE(excluded.description, relationships.description)",
188        params![
189            namespace,
190            source_id,
191            target_id,
192            rel.relation,
193            rel.strength,
194            rel.description
195        ],
196    )?;
197    let id: i64 = conn.query_row(
198        "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
199        params![source_id, target_id, rel.relation],
200        |r| r.get(0),
201    )?;
202    Ok(id)
203}
204
205/// Links a memory to an entity in the `memory_entities` join table.
206///
207/// # Errors
208///
209/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
210pub fn link_memory_entity(
211    conn: &Connection,
212    memory_id: i64,
213    entity_id: i64,
214) -> Result<(), AppError> {
215    conn.execute(
216        "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
217        params![memory_id, entity_id],
218    )?;
219    Ok(())
220}
221
222/// Links a memory to a relationship in the `memory_relationships` join table.
223///
224/// # Errors
225///
226/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
227pub fn link_memory_relationship(
228    conn: &Connection,
229    memory_id: i64,
230    rel_id: i64,
231) -> Result<(), AppError> {
232    conn.execute(
233        "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
234        params![memory_id, rel_id],
235    )?;
236    Ok(())
237}
238
239/// GAP-SG-52: removes the curated `memory_entities` binding between a memory
240/// and an entity. Unlike `prune-ner` (which targets an entity across every
241/// memory), this surgically unlinks a single `(memory_id, entity_id)` pair —
242/// covering bindings created via `remember --graph-stdin`. Returns the number
243/// of junction rows removed (0 or 1).
244///
245/// # Errors
246///
247/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
248pub fn unlink_memory_entity(
249    conn: &Connection,
250    memory_id: i64,
251    entity_id: i64,
252) -> Result<u64, AppError> {
253    let affected = conn.execute(
254        "DELETE FROM memory_entities WHERE memory_id = ?1 AND entity_id = ?2",
255        params![memory_id, entity_id],
256    )?;
257    Ok(affected as u64)
258}
259
260/// GAP-SG-51: clears every `memory_entities` and `memory_relationships`
261/// binding for a memory so a `--force-merge --replace-graph` update can install
262/// an authoritative set (including the empty set). The entities and
263/// relationships themselves are preserved; only the junction rows for this
264/// memory are removed. Returns `(entity_bindings_removed, relationship_bindings_removed)`.
265///
266/// # Errors
267///
268/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
269pub fn clear_memory_graph_bindings(
270    conn: &Connection,
271    memory_id: i64,
272) -> Result<(u64, u64), AppError> {
273    let entities_removed = conn.execute(
274        "DELETE FROM memory_entities WHERE memory_id = ?1",
275        params![memory_id],
276    )? as u64;
277    let rels_removed = conn.execute(
278        "DELETE FROM memory_relationships WHERE memory_id = ?1",
279        params![memory_id],
280    )? as u64;
281    Ok((entities_removed, rels_removed))
282}
283
284/// Increments the `degree` counter of an entity by one.
285///
286/// # Errors
287///
288/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
289pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
290    conn.execute(
291        "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
292        params![entity_id],
293    )?;
294    Ok(())
295}
296
297/// Looks up the entity by name and namespace. Returns the id when it exists.
298///
299/// # Errors
300///
301/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
302pub fn find_entity_id(
303    conn: &Connection,
304    namespace: &str,
305    name: &str,
306) -> Result<Option<i64>, AppError> {
307    // Normalize the lookup name so it matches the normalized names written by
308    // `upsert_entity`. Without this, an entity written through normalization
309    // (e.g. "Foo Bar" -> "foo-bar") would be unreachable by its original
310    // spelling, breaking delete-entity, reclassify, merge-entities, rename and
311    // memory-entities lookups.
312    let name = normalize_entity_name(name);
313    let mut stmt =
314        conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
315    match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
316        Ok(id) => Ok(Some(id)),
317        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
318        Err(e) => Err(AppError::Database(e)),
319    }
320}
321
322/// Structure representing an existing relation.
323#[derive(Debug, Serialize)]
324pub struct RelationshipRow {
325    pub id: i64,
326    pub namespace: String,
327    pub source_id: i64,
328    pub target_id: i64,
329    pub relation: String,
330    pub weight: f64,
331    pub description: Option<String>,
332}
333
334/// Looks up a specific relation by (source_id, target_id, relation).
335///
336/// # Errors
337///
338/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
339pub fn find_relationship(
340    conn: &Connection,
341    source_id: i64,
342    target_id: i64,
343    relation: &str,
344) -> Result<Option<RelationshipRow>, AppError> {
345    let mut stmt = conn.prepare_cached(
346        "SELECT id, namespace, source_id, target_id, relation, weight, description
347         FROM relationships
348         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
349    )?;
350    match stmt.query_row(params![source_id, target_id, relation], |r| {
351        Ok(RelationshipRow {
352            id: r.get(0)?,
353            namespace: r.get(1)?,
354            source_id: r.get(2)?,
355            target_id: r.get(3)?,
356            relation: r.get(4)?,
357            weight: r.get(5)?,
358            description: r.get(6)?,
359        })
360    }) {
361        Ok(row) => Ok(Some(row)),
362        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
363        Err(e) => Err(AppError::Database(e)),
364    }
365}
366
367/// Creates a relation if it does not exist (returns action="created")
368/// or returns the existing relation (action="already_exists") with updated weight.
369///
370/// # Errors
371///
372/// - [`AppError::Database`] — SQLite query or constraint failure.
373/// - [`AppError::Validation`] — self-link attempt (source equals target).
374pub fn create_or_fetch_relationship(
375    conn: &Connection,
376    namespace: &str,
377    source_id: i64,
378    target_id: i64,
379    relation: &str,
380    weight: f64,
381    description: Option<&str>,
382) -> Result<(i64, bool), AppError> {
383    // Check if it exists first; update weight if different.
384    let existing = find_relationship(conn, source_id, target_id, relation)?;
385    if let Some(row) = existing {
386        if (row.weight - weight).abs() > f64::EPSILON {
387            conn.execute(
388                "UPDATE relationships SET weight = ?1 WHERE id = ?2",
389                params![weight, row.id],
390            )?;
391        }
392        return Ok((row.id, false));
393    }
394    conn.execute(
395        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
396         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
397        params![
398            namespace,
399            source_id,
400            target_id,
401            relation,
402            weight,
403            description
404        ],
405    )?;
406    let id: i64 = conn.query_row(
407        "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
408        params![source_id, target_id, relation],
409        |r| r.get(0),
410    )?;
411    Ok((id, true))
412}
413
414/// Removes a relation by id and cleans up memory_relationships.
415///
416/// # Errors
417///
418/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
419pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
420    conn.execute(
421        "DELETE FROM memory_relationships WHERE relationship_id = ?1",
422        params![relationship_id],
423    )?;
424    conn.execute(
425        "DELETE FROM relationships WHERE id = ?1",
426        params![relationship_id],
427    )?;
428    Ok(())
429}
430
431/// Recalculates the `degree` field of an entity.
432///
433/// # Errors
434///
435/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
436pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
437    conn.execute(
438        "UPDATE entities
439         SET degree = (SELECT COUNT(*) FROM relationships
440                       WHERE source_id = entities.id OR target_id = entities.id)
441         WHERE id = ?1",
442        params![entity_id],
443    )?;
444    Ok(())
445}
446
447/// Entity row with enough data for graph export/query.
448#[derive(Debug, Serialize, Clone)]
449pub struct EntityNode {
450    pub id: i64,
451    pub name: String,
452    pub namespace: String,
453    pub kind: String,
454}
455
456/// Lists entities, filtering by namespace if provided.
457///
458/// # Errors
459///
460/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
461pub fn list_entities(
462    conn: &Connection,
463    namespace: Option<&str>,
464) -> Result<Vec<EntityNode>, AppError> {
465    if let Some(ns) = namespace {
466        let mut stmt = conn.prepare_cached(
467            "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
468        )?;
469        let rows = stmt
470            .query_map(params![ns], |r| {
471                Ok(EntityNode {
472                    id: r.get(0)?,
473                    name: r.get(1)?,
474                    namespace: r.get(2)?,
475                    kind: r.get(3)?,
476                })
477            })?
478            .collect::<Result<Vec<_>, _>>()?;
479        Ok(rows)
480    } else {
481        let mut stmt = conn.prepare_cached(
482            "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
483        )?;
484        let rows = stmt
485            .query_map([], |r| {
486                Ok(EntityNode {
487                    id: r.get(0)?,
488                    name: r.get(1)?,
489                    namespace: r.get(2)?,
490                    kind: r.get(3)?,
491                })
492            })?
493            .collect::<Result<Vec<_>, _>>()?;
494        Ok(rows)
495    }
496}
497
498/// Lists relations filtered by namespace (of source/target entities).
499///
500/// # Errors
501///
502/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
503pub fn list_relationships_by_namespace(
504    conn: &Connection,
505    namespace: Option<&str>,
506) -> Result<Vec<RelationshipRow>, AppError> {
507    if let Some(ns) = namespace {
508        let mut stmt = conn.prepare_cached(
509            "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
510             FROM relationships r
511             JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
512             JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
513             ORDER BY r.id",
514        )?;
515        let rows = stmt
516            .query_map(params![ns], |r| {
517                Ok(RelationshipRow {
518                    id: r.get(0)?,
519                    namespace: r.get(1)?,
520                    source_id: r.get(2)?,
521                    target_id: r.get(3)?,
522                    relation: r.get(4)?,
523                    weight: r.get(5)?,
524                    description: r.get(6)?,
525                })
526            })?
527            .collect::<Result<Vec<_>, _>>()?;
528        Ok(rows)
529    } else {
530        let mut stmt = conn.prepare_cached(
531            "SELECT id, namespace, source_id, target_id, relation, weight, description
532             FROM relationships ORDER BY id",
533        )?;
534        let rows = stmt
535            .query_map([], |r| {
536                Ok(RelationshipRow {
537                    id: r.get(0)?,
538                    namespace: r.get(1)?,
539                    source_id: r.get(2)?,
540                    target_id: r.get(3)?,
541                    relation: r.get(4)?,
542                    weight: r.get(5)?,
543                    description: r.get(6)?,
544                })
545            })?
546            .collect::<Result<Vec<_>, _>>()?;
547        Ok(rows)
548    }
549}
550
551/// Locates orphan entities: no link in memory_entities and no relations.
552///
553/// # Errors
554///
555/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
556pub fn find_orphan_entity_ids(
557    conn: &Connection,
558    namespace: Option<&str>,
559) -> Result<Vec<i64>, AppError> {
560    if let Some(ns) = namespace {
561        let mut stmt = conn.prepare_cached(
562            "SELECT e.id FROM entities e
563             WHERE e.namespace = ?1
564               AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
565               AND NOT EXISTS (
566                   SELECT 1 FROM relationships r
567                   WHERE r.source_id = e.id OR r.target_id = e.id
568               )",
569        )?;
570        let ids = stmt
571            .query_map(params![ns], |r| r.get::<_, i64>(0))?
572            .collect::<Result<Vec<_>, _>>()?;
573        Ok(ids)
574    } else {
575        let mut stmt = conn.prepare_cached(
576            "SELECT e.id FROM entities e
577             WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
578               AND NOT EXISTS (
579                   SELECT 1 FROM relationships r
580                   WHERE r.source_id = e.id OR r.target_id = e.id
581               )",
582        )?;
583        let ids = stmt
584            .query_map([], |r| r.get::<_, i64>(0))?
585            .collect::<Result<Vec<_>, _>>()?;
586        Ok(ids)
587    }
588}
589
590/// Deletes entities and their associated vectors. Returns the number of entities removed.
591///
592/// # Errors
593///
594/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
595pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
596    if entity_ids.is_empty() {
597        return Ok(0);
598    }
599    let mut removed = 0usize;
600    for id in entity_ids {
601        // FK CASCADE on entity_embeddings handles cleanup automatically.
602        let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
603        let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
604        removed += affected;
605    }
606    Ok(removed)
607}
608
609/// Counts relationships matching the given relation type within a namespace.
610///
611/// Used by `prune-relations --dry-run` to preview the number of relationships
612/// that would be deleted without actually modifying the database.
613///
614/// # Errors
615///
616/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
617pub fn count_relationships_by_relation(
618    conn: &Connection,
619    namespace: &str,
620    relation: &str,
621) -> Result<usize, AppError> {
622    let count: i64 = conn.query_row(
623        "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
624        params![namespace, relation],
625        |r| r.get(0),
626    )?;
627    Ok(count as usize)
628}
629
630/// Returns unique entity names involved in relationships of the given type.
631///
632/// Queries both source and target sides of every matching relationship row,
633/// deduplicates via `DISTINCT`, and returns the names in alphabetical order.
634///
635/// # Errors
636///
637/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
638pub fn list_entity_names_by_relation(
639    conn: &Connection,
640    namespace: &str,
641    relation: &str,
642) -> Result<Vec<String>, AppError> {
643    let mut stmt = conn.prepare_cached(
644        "SELECT DISTINCT e.name FROM entities e
645         INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
646         WHERE r.namespace = ?1 AND r.relation = ?2
647         ORDER BY e.name",
648    )?;
649    let names: Vec<String> = stmt
650        .query_map(params![namespace, relation], |row| row.get(0))?
651        .collect::<Result<Vec<_>, _>>()?;
652    Ok(names)
653}
654
655/// Deletes all relationships matching a relation type within a namespace.
656///
657/// Operates in chunks of 1000 to avoid holding long write locks and blocking
658/// WAL readers. After deletion, recalculates degree for every affected entity.
659///
660/// Returns `(count_deleted, affected_entity_ids)`.
661///
662/// # Errors
663///
664/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
665pub fn delete_relationships_by_relation(
666    conn: &Connection,
667    namespace: &str,
668    relation: &str,
669) -> Result<(usize, Vec<i64>), AppError> {
670    // Step 1: collect all affected entity IDs before deletion.
671    let mut stmt = conn.prepare_cached(
672        "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
673         UNION
674         SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
675    )?;
676    let entity_ids: Vec<i64> = stmt
677        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
678        .collect::<Result<Vec<_>, _>>()?;
679
680    // Step 2: collect relationship IDs to delete.
681    let mut id_stmt =
682        conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
683    let rel_ids: Vec<i64> = id_stmt
684        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
685        .collect::<Result<Vec<_>, _>>()?;
686
687    // Step 3: delete in chunks of 1000 (memory_relationships + relationships).
688    let mut total_deleted: usize = 0;
689    for chunk in rel_ids.chunks(1000) {
690        for &rel_id in chunk {
691            conn.execute(
692                "DELETE FROM memory_relationships WHERE relationship_id = ?1",
693                params![rel_id],
694            )?;
695            let affected =
696                conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
697            total_deleted += affected;
698        }
699    }
700
701    // Step 4: recalculate degree for all affected entities.
702    for &eid in &entity_ids {
703        recalculate_degree(conn, eid)?;
704    }
705
706    Ok((total_deleted, entity_ids))
707}
708
709/// Searches the `entity_embeddings` table for the k nearest neighbours
710/// using pure-Rust cosine similarity.
711///
712/// v1.0.76: sqlite-vec was removed. The full table scan + in-process
713/// cosine is O(N × D) per call. For namespaces with more than ~10k
714/// entities, the operator should rely on FTS5 (`hybrid-search`) for
715/// coarse filtering before reaching this function.
716///
717/// # Errors
718///
719/// - [`AppError::Database`] — SQLite query failure.
720/// - [`AppError::Embedding`] — invalid or mismatched embedding dimension.
721pub fn knn_search(
722    conn: &Connection,
723    embedding: &[f32],
724    namespace: &str,
725    k: usize,
726) -> Result<Vec<(i64, f32)>, AppError> {
727    if embedding.len() != crate::constants::embedding_dim() {
728        return Err(AppError::Embedding(format!(
729            "knn_search embedding has {} dims, expected {}",
730            embedding.len(),
731            crate::constants::embedding_dim()
732        )));
733    }
734    let mut stmt = conn.prepare_cached(
735        "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
736    )?;
737    let mut scored: Vec<(i64, f32)> = stmt
738        .query_map(params![namespace], |r| {
739            let id: i64 = r.get(0)?;
740            let bytes: Vec<u8> = r.get(1)?;
741            Ok((id, bytes))
742        })?
743        .filter_map(|row| {
744            row.ok().and_then(|(id, bytes)| {
745                let stored = crate::embedder::bytes_to_f32(&bytes);
746                if stored.len() != embedding.len() {
747                    return None;
748                }
749                let score = crate::similarity::cosine_similarity(embedding, &stored);
750                Some((id, score))
751            })
752        })
753        .collect();
754    // `cosine_similarity` returns a value in [-1.0, 1.0]; 1.0 is the
755    // best match. Sort descending and truncate to `k`.
756    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
757    scored.truncate(k);
758    Ok(scored)
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::constants::embedding_dim;
765    use crate::entity_type::EntityType;
766    use crate::storage::connection::register_vec_extension;
767    use rusqlite::Connection;
768    use tempfile::TempDir;
769
770    type TestResult = Result<(), Box<dyn std::error::Error>>;
771
772    fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
773        register_vec_extension();
774        let tmp = TempDir::new()?;
775        let db_path = tmp.path().join("test.db");
776        let mut conn = Connection::open(&db_path)?;
777        crate::migrations::runner().run(&mut conn)?;
778        Ok((tmp, conn))
779    }
780
781    fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
782        conn.execute(
783            "INSERT INTO memories (namespace, name, type, description, body, body_hash)
784             VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
785            [],
786        )?;
787        Ok(conn.last_insert_rowid())
788    }
789
790    fn new_entity_helper(name: &str) -> NewEntity {
791        NewEntity {
792            name: name.to_string(),
793            entity_type: EntityType::Project,
794            description: None,
795        }
796    }
797
798    fn embedding_zero() -> Vec<f32> {
799        vec![0.0f32; embedding_dim()]
800    }
801
802    // ------------------------------------------------------------------ //
803    // upsert_entity
804    // ------------------------------------------------------------------ //
805
806    #[test]
807    fn test_upsert_entity_creates_new() -> TestResult {
808        let (_tmp, conn) = setup_db()?;
809        let e = new_entity_helper("projeto-alpha");
810        let id = upsert_entity(&conn, "global", &e)?;
811        assert!(id > 0);
812        Ok(())
813    }
814
815    #[test]
816    fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
817        let (_tmp, conn) = setup_db()?;
818        let e = new_entity_helper("projeto-beta");
819        let id1 = upsert_entity(&conn, "global", &e)?;
820        let id2 = upsert_entity(&conn, "global", &e)?;
821        assert_eq!(id1, id2);
822        Ok(())
823    }
824
825    #[test]
826    fn test_upsert_entity_updates_description() -> TestResult {
827        let (_tmp, conn) = setup_db()?;
828        let e1 = new_entity_helper("projeto-gamma");
829        let id1 = upsert_entity(&conn, "global", &e1)?;
830
831        let e2 = NewEntity {
832            name: "projeto-gamma".to_string(),
833            entity_type: EntityType::Tool,
834            description: Some("nova desc".to_string()),
835        };
836        let id2 = upsert_entity(&conn, "global", &e2)?;
837        assert_eq!(id1, id2);
838
839        let desc: Option<String> = conn.query_row(
840            "SELECT description FROM entities WHERE id = ?1",
841            params![id1],
842            |r| r.get(0),
843        )?;
844        assert_eq!(desc.as_deref(), Some("nova desc"));
845        Ok(())
846    }
847
848    #[test]
849    fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
850        let (_tmp, conn) = setup_db()?;
851        let e = new_entity_helper("compartilhada");
852        let id1 = upsert_entity(&conn, "ns1", &e)?;
853        let id2 = upsert_entity(&conn, "ns2", &e)?;
854        assert_ne!(id1, id2);
855        Ok(())
856    }
857
858    // ------------------------------------------------------------------ //
859    // upsert_entity_vec — covers DELETE+INSERT (new branch after the OOM fix)
860    // ------------------------------------------------------------------ //
861
862    // v1.1.1 (P1): an empty embedding must NOT create a vector row, so the
863    // entity stays visible to `enrich re-embed --target entities`.
864    #[test]
865    fn test_upsert_entity_vec_empty_embedding_skips_row() -> TestResult {
866        let (_tmp, conn) = setup_db()?;
867        let e = new_entity_helper("vec-vazia");
868        let entity_id = upsert_entity(&conn, "global", &e)?;
869
870        upsert_entity_vec(
871            &conn,
872            entity_id,
873            "global",
874            EntityType::Project,
875            &[],
876            "vec-vazia",
877        )?;
878
879        let count: i64 = conn.query_row(
880            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
881            params![entity_id],
882            |r| r.get(0),
883        )?;
884        assert_eq!(count, 0, "empty embedding must not persist a row");
885        Ok(())
886    }
887
888    // v1.1.1 (P1): an empty embedding must NOT delete an existing live vector.
889    #[test]
890    #[serial_test::serial(env)]
891    fn test_upsert_entity_vec_empty_embedding_preserves_existing_row() -> TestResult {
892        let (_tmp, conn) = setup_db()?;
893        let e = new_entity_helper("vec-preservada");
894        let entity_id = upsert_entity(&conn, "global", &e)?;
895        let emb = embedding_zero();
896        upsert_entity_vec(
897            &conn,
898            entity_id,
899            "global",
900            EntityType::Project,
901            &emb,
902            "vec-preservada",
903        )?;
904
905        upsert_entity_vec(
906            &conn,
907            entity_id,
908            "global",
909            EntityType::Project,
910            &[],
911            "vec-preservada",
912        )?;
913
914        let count: i64 = conn.query_row(
915            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
916            params![entity_id],
917            |r| r.get(0),
918        )?;
919        assert_eq!(count, 1, "existing vector must survive an empty upsert");
920        Ok(())
921    }
922
923    #[test]
924    #[serial_test::serial(env)]
925    fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
926        let (_tmp, conn) = setup_db()?;
927        let e = new_entity_helper("vec-nova");
928        let entity_id = upsert_entity(&conn, "global", &e)?;
929        let emb = embedding_zero();
930
931        let result = upsert_entity_vec(
932            &conn,
933            entity_id,
934            "global",
935            EntityType::Project,
936            &emb,
937            "vec-nova",
938        );
939        assert!(result.is_ok(), "first insertion must succeed");
940
941        let count: i64 = conn.query_row(
942            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
943            params![entity_id],
944            |r| r.get(0),
945        )?;
946        assert_eq!(count, 1, "must have exactly one row after insertion");
947        Ok(())
948    }
949
950    #[test]
951    #[serial_test::serial(env)]
952    fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
953        // Covers the branch where DELETE removes the existing row before INSERT.
954        let (_tmp, conn) = setup_db()?;
955        let e = new_entity_helper("vec-existente");
956        let entity_id = upsert_entity(&conn, "global", &e)?;
957        let emb = embedding_zero();
958
959        upsert_entity_vec(
960            &conn,
961            entity_id,
962            "global",
963            EntityType::Project,
964            &emb,
965            "vec-existente",
966        )?;
967
968        // Second call: DELETE returns 1 removed row, INSERT must succeed.
969        let result = upsert_entity_vec(
970            &conn,
971            entity_id,
972            "global",
973            EntityType::Tool,
974            &emb,
975            "vec-existente",
976        );
977        assert!(
978            result.is_ok(),
979            "second insertion (replace) must succeed: {result:?}"
980        );
981
982        let count: i64 = conn.query_row(
983            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
984            params![entity_id],
985            |r| r.get(0),
986        )?;
987        assert_eq!(count, 1, "must have exactly one row after replacement");
988        Ok(())
989    }
990
991    #[test]
992    #[serial_test::serial(env)]
993    fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
994        let (_tmp, conn) = setup_db()?;
995        let emb = embedding_zero();
996
997        for i in 0..3i64 {
998            let nome = format!("ent-{i}");
999            let e = new_entity_helper(&nome);
1000            let entity_id = upsert_entity(&conn, "global", &e)?;
1001            upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
1002        }
1003
1004        let count: i64 =
1005            conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
1006        assert_eq!(
1007            count, 3,
1008            "must have three distinct rows in entity_embeddings"
1009        );
1010        Ok(())
1011    }
1012
1013    // ------------------------------------------------------------------ //
1014    // find_entity_id
1015    // ------------------------------------------------------------------ //
1016
1017    #[test]
1018    fn test_find_entity_id_existing_returns_some() -> TestResult {
1019        let (_tmp, conn) = setup_db()?;
1020        let e = new_entity_helper("entidade-busca");
1021        let id_inserido = upsert_entity(&conn, "global", &e)?;
1022        let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
1023        assert_eq!(id_encontrado, Some(id_inserido));
1024        Ok(())
1025    }
1026
1027    #[test]
1028    fn test_find_entity_id_missing_returns_none() -> TestResult {
1029        let (_tmp, conn) = setup_db()?;
1030        let id = find_entity_id(&conn, "global", "nao-existe")?;
1031        assert_eq!(id, None);
1032        Ok(())
1033    }
1034
1035    // ------------------------------------------------------------------ //
1036    // delete_entities_by_ids
1037    // ------------------------------------------------------------------ //
1038
1039    #[test]
1040    fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
1041        let (_tmp, conn) = setup_db()?;
1042        let removed = delete_entities_by_ids(&conn, &[])?;
1043        assert_eq!(removed, 0);
1044        Ok(())
1045    }
1046
1047    #[test]
1048    fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
1049        let (_tmp, conn) = setup_db()?;
1050        let e = new_entity_helper("to-delete");
1051        let entity_id = upsert_entity(&conn, "global", &e)?;
1052
1053        let removed = delete_entities_by_ids(&conn, &[entity_id])?;
1054        assert_eq!(removed, 1);
1055
1056        let id = find_entity_id(&conn, "global", "to-delete")?;
1057        assert_eq!(id, None, "entity must have been removed");
1058        Ok(())
1059    }
1060
1061    #[test]
1062    fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
1063        let (_tmp, conn) = setup_db()?;
1064        let removed = delete_entities_by_ids(&conn, &[9999])?;
1065        assert_eq!(removed, 0);
1066        Ok(())
1067    }
1068
1069    #[test]
1070    fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
1071        let (_tmp, conn) = setup_db()?;
1072        let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
1073        let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
1074        let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
1075
1076        let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
1077        assert_eq!(removed, 2);
1078
1079        assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
1080        assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
1081        assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
1082        let _ = id3;
1083        Ok(())
1084    }
1085
1086    #[test]
1087    fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
1088        let (_tmp, conn) = setup_db()?;
1089        let e = new_entity_helper("del-com-vec");
1090        let entity_id = upsert_entity(&conn, "global", &e)?;
1091        let emb = embedding_zero();
1092        upsert_entity_vec(
1093            &conn,
1094            entity_id,
1095            "global",
1096            EntityType::Project,
1097            &emb,
1098            "del-com-vec",
1099        )?;
1100
1101        let count_antes: i64 = conn.query_row(
1102            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1103            params![entity_id],
1104            |r| r.get(0),
1105        )?;
1106        assert_eq!(count_antes, 1);
1107
1108        delete_entities_by_ids(&conn, &[entity_id])?;
1109
1110        let count_depois: i64 = conn.query_row(
1111            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1112            params![entity_id],
1113            |r| r.get(0),
1114        )?;
1115        assert_eq!(
1116            count_depois, 0,
1117            "entity_embeddings deve ser limpo junto com entities"
1118        );
1119        Ok(())
1120    }
1121
1122    // ------------------------------------------------------------------ //
1123    // upsert_relationship / find_relationship
1124    // ------------------------------------------------------------------ //
1125
1126    #[test]
1127    fn test_upsert_relationship_creates_new() -> TestResult {
1128        let (_tmp, conn) = setup_db()?;
1129        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1130        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1131
1132        let rel = NewRelationship {
1133            source: "rel-a".to_string(),
1134            target: "rel-b".to_string(),
1135            relation: "uses".to_string(),
1136            strength: 0.8,
1137            description: None,
1138        };
1139        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1140        assert!(rel_id > 0);
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn test_upsert_relationship_idempotent() -> TestResult {
1146        let (_tmp, conn) = setup_db()?;
1147        let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1148        let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1149
1150        let rel = NewRelationship {
1151            source: "idem-a".to_string(),
1152            target: "idem-b".to_string(),
1153            relation: "uses".to_string(),
1154            strength: 0.5,
1155            description: None,
1156        };
1157        let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1158        let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1159        assert_eq!(id1, id2);
1160        Ok(())
1161    }
1162
1163    #[test]
1164    fn test_find_relationship_existing() -> TestResult {
1165        let (_tmp, conn) = setup_db()?;
1166        let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1167        let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1168
1169        let rel = NewRelationship {
1170            source: "fr-a".to_string(),
1171            target: "fr-b".to_string(),
1172            relation: "depends_on".to_string(),
1173            strength: 0.7,
1174            description: None,
1175        };
1176        upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1177
1178        let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1179        let row = encontrada.ok_or("relationship should exist")?;
1180        assert_eq!(row.source_id, id_a);
1181        assert_eq!(row.target_id, id_b);
1182        assert!((row.weight - 0.7).abs() < 1e-9);
1183        Ok(())
1184    }
1185
1186    #[test]
1187    fn test_find_relationship_missing_returns_none() -> TestResult {
1188        let (_tmp, conn) = setup_db()?;
1189        let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1190        assert!(resultado.is_none());
1191        Ok(())
1192    }
1193
1194    // ------------------------------------------------------------------ //
1195    // link_memory_entity / link_memory_relationship
1196    // ------------------------------------------------------------------ //
1197
1198    #[test]
1199    fn test_link_memory_entity_idempotent() -> TestResult {
1200        let (_tmp, conn) = setup_db()?;
1201        let memory_id = insert_memory(&conn)?;
1202        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1203
1204        link_memory_entity(&conn, memory_id, entity_id)?;
1205        let resultado = link_memory_entity(&conn, memory_id, entity_id);
1206        assert!(
1207            resultado.is_ok(),
1208            "INSERT OR IGNORE must not fail on duplicate"
1209        );
1210        Ok(())
1211    }
1212
1213    #[test]
1214    fn test_link_memory_relationship_idempotent() -> TestResult {
1215        let (_tmp, conn) = setup_db()?;
1216        let memory_id = insert_memory(&conn)?;
1217        let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1218        let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1219
1220        let rel = NewRelationship {
1221            source: "mr-a".to_string(),
1222            target: "mr-b".to_string(),
1223            relation: "uses".to_string(),
1224            strength: 0.5,
1225            description: None,
1226        };
1227        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1228
1229        link_memory_relationship(&conn, memory_id, rel_id)?;
1230        let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1231        assert!(
1232            resultado.is_ok(),
1233            "INSERT OR IGNORE must not fail on duplicate"
1234        );
1235        Ok(())
1236    }
1237
1238    // ------------------------------------------------------------------ //
1239    // increment_degree / recalculate_degree
1240    // ------------------------------------------------------------------ //
1241
1242    #[test]
1243    fn test_increment_degree_increases_counter() -> TestResult {
1244        let (_tmp, conn) = setup_db()?;
1245        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1246
1247        increment_degree(&conn, entity_id)?;
1248        increment_degree(&conn, entity_id)?;
1249
1250        let degree: i64 = conn.query_row(
1251            "SELECT degree FROM entities WHERE id = ?1",
1252            params![entity_id],
1253            |r| r.get(0),
1254        )?;
1255        assert_eq!(degree, 2);
1256        Ok(())
1257    }
1258
1259    #[test]
1260    fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1261        let (_tmp, conn) = setup_db()?;
1262        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1263        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1264        let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1265
1266        let rel1 = NewRelationship {
1267            source: "rc-a".to_string(),
1268            target: "rc-b".to_string(),
1269            relation: "uses".to_string(),
1270            strength: 0.5,
1271            description: None,
1272        };
1273        let rel2 = NewRelationship {
1274            source: "rc-c".to_string(),
1275            target: "rc-a".to_string(),
1276            relation: "depends_on".to_string(),
1277            strength: 0.5,
1278            description: None,
1279        };
1280        upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1281        upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1282
1283        recalculate_degree(&conn, id_a)?;
1284
1285        let degree: i64 = conn.query_row(
1286            "SELECT degree FROM entities WHERE id = ?1",
1287            params![id_a],
1288            |r| r.get(0),
1289        )?;
1290        assert_eq!(
1291            degree, 2,
1292            "rc-a appears in two relationships (source+target)"
1293        );
1294        Ok(())
1295    }
1296
1297    // ------------------------------------------------------------------ //
1298    // find_orphan_entity_ids
1299    // ------------------------------------------------------------------ //
1300
1301    #[test]
1302    fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1303        let (_tmp, conn) = setup_db()?;
1304        let memory_id = insert_memory(&conn)?;
1305        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1306        link_memory_entity(&conn, memory_id, entity_id)?;
1307
1308        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1309        assert!(!orfas.contains(&entity_id));
1310        Ok(())
1311    }
1312
1313    #[test]
1314    fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1315        let (_tmp, conn) = setup_db()?;
1316        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1317
1318        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1319        assert!(orfas.contains(&entity_id));
1320        Ok(())
1321    }
1322
1323    #[test]
1324    fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1325        let (_tmp, conn) = setup_db()?;
1326        let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1327        let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1328
1329        let orfas = find_orphan_entity_ids(&conn, None)?;
1330        assert!(orfas.contains(&id1));
1331        assert!(orfas.contains(&id2));
1332        Ok(())
1333    }
1334
1335    // ------------------------------------------------------------------ //
1336    // list_entities / list_relationships_by_namespace
1337    // ------------------------------------------------------------------ //
1338
1339    #[test]
1340    fn test_list_entities_with_namespace() -> TestResult {
1341        let (_tmp, conn) = setup_db()?;
1342        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1343        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1344        upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1345
1346        let lista = list_entities(&conn, Some("le-ns"))?;
1347        assert_eq!(lista.len(), 2);
1348        assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn test_list_entities_without_namespace_returns_all() -> TestResult {
1354        let (_tmp, conn) = setup_db()?;
1355        upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1356        upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1357
1358        let lista = list_entities(&conn, None)?;
1359        assert!(lista.len() >= 2);
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1365        let (_tmp, conn) = setup_db()?;
1366        let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1367        let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1368
1369        let rel = NewRelationship {
1370            source: "lr-a".to_string(),
1371            target: "lr-b".to_string(),
1372            relation: "uses".to_string(),
1373            strength: 0.5,
1374            description: None,
1375        };
1376        upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1377
1378        let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1379        assert!(!lista.is_empty());
1380        assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1381        Ok(())
1382    }
1383
1384    // ------------------------------------------------------------------ //
1385    // delete_relationship_by_id / create_or_fetch_relationship
1386    // ------------------------------------------------------------------ //
1387
1388    #[test]
1389    fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1390        let (_tmp, conn) = setup_db()?;
1391        let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1392        let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1393
1394        let rel = NewRelationship {
1395            source: "dr-a".to_string(),
1396            target: "dr-b".to_string(),
1397            relation: "uses".to_string(),
1398            strength: 0.5,
1399            description: None,
1400        };
1401        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1402
1403        delete_relationship_by_id(&conn, rel_id)?;
1404
1405        let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1406        assert!(encontrada.is_none(), "relationship must have been removed");
1407        Ok(())
1408    }
1409
1410    #[test]
1411    fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1412        let (_tmp, conn) = setup_db()?;
1413        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1414        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1415
1416        let (rel_id, created) =
1417            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1418        assert!(rel_id > 0);
1419        assert!(created);
1420        Ok(())
1421    }
1422
1423    #[test]
1424    fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1425        let (_tmp, conn) = setup_db()?;
1426        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1427        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1428
1429        create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1430        let (_, created) =
1431            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1432        assert!(
1433            !created,
1434            "second call must return the existing relationship"
1435        );
1436        Ok(())
1437    }
1438
1439    // ------------------------------------------------------------------ //
1440    // serde alias: field "type" accepted as a synonym for "entity_type"
1441    // ------------------------------------------------------------------ //
1442
1443    #[test]
1444    fn accepts_type_field_as_alias() -> TestResult {
1445        let json = r#"{"name": "X", "type": "concept"}"#;
1446        let ent: NewEntity = serde_json::from_str(json)?;
1447        assert_eq!(ent.entity_type, EntityType::Concept);
1448        Ok(())
1449    }
1450
1451    #[test]
1452    fn accepts_canonical_entity_type_field() -> TestResult {
1453        let json = r#"{"name": "X", "entity_type": "concept"}"#;
1454        let ent: NewEntity = serde_json::from_str(json)?;
1455        assert_eq!(ent.entity_type, EntityType::Concept);
1456        Ok(())
1457    }
1458
1459    #[test]
1460    fn both_fields_present_yields_duplicate_error() {
1461        // having both entity_type and type in the same JSON is a duplicate and must fail
1462        let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1463        let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1464        assert!(
1465            resultado.is_err(),
1466            "both fields in the same JSON are a duplicate"
1467        );
1468    }
1469
1470    #[test]
1471    fn validate_entity_name_accepts_valid() {
1472        assert!(validate_entity_name("rust-lang").is_ok());
1473        assert!(validate_entity_name("sqlite-graphrag").is_ok());
1474        assert!(validate_entity_name("ab").is_ok());
1475    }
1476
1477    #[test]
1478    fn validate_entity_name_rejects_short() {
1479        assert!(validate_entity_name("a").is_err());
1480        assert!(validate_entity_name("").is_err());
1481    }
1482
1483    #[test]
1484    fn validate_entity_name_rejects_newlines() {
1485        assert!(validate_entity_name("foo\nbar").is_err());
1486        assert!(validate_entity_name("foo\rbar").is_err());
1487    }
1488
1489    #[test]
1490    fn validate_entity_name_rejects_short_allcaps() {
1491        assert!(validate_entity_name("RAM").is_err());
1492        assert!(validate_entity_name("NAO").is_err());
1493        assert!(validate_entity_name("OK").is_err());
1494    }
1495
1496    #[test]
1497    fn validate_entity_name_accepts_long_allcaps() {
1498        assert!(validate_entity_name("SQLITE").is_ok());
1499        assert!(validate_entity_name("GRAPHRAG").is_ok());
1500    }
1501
1502    #[test]
1503    fn validate_entity_name_accepts_mixed_case() {
1504        assert!(validate_entity_name("FTS5").is_ok()); // 4 chars but has digit
1505        assert!(validate_entity_name("WAL").is_err()); // 3 chars ALL_CAPS
1506    }
1507
1508    // GAP-SG-52: unlink_memory_entity removes exactly the targeted binding.
1509    #[test]
1510    fn test_unlink_memory_entity_removes_single_binding() -> TestResult {
1511        let (_tmp, conn) = setup_db()?;
1512        let memory_id = insert_memory(&conn)?;
1513        let e1 = upsert_entity(&conn, "global", &new_entity_helper("entidade-um"))?;
1514        let e2 = upsert_entity(&conn, "global", &new_entity_helper("entidade-dois"))?;
1515        link_memory_entity(&conn, memory_id, e1)?;
1516        link_memory_entity(&conn, memory_id, e2)?;
1517
1518        let removed = unlink_memory_entity(&conn, memory_id, e1)?;
1519        assert_eq!(removed, 1);
1520
1521        // e1 binding gone, e2 binding kept.
1522        let remaining: i64 = conn.query_row(
1523            "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1524            params![memory_id],
1525            |r| r.get(0),
1526        )?;
1527        assert_eq!(remaining, 1);
1528
1529        // Idempotent: a second unlink of the same pair removes nothing.
1530        assert_eq!(unlink_memory_entity(&conn, memory_id, e1)?, 0);
1531        Ok(())
1532    }
1533
1534    // GAP-SG-51: clear_memory_graph_bindings zeroes every binding for a memory.
1535    #[test]
1536    fn test_clear_memory_graph_bindings_clears_all() -> TestResult {
1537        let (_tmp, conn) = setup_db()?;
1538        let memory_id = insert_memory(&conn)?;
1539        let e1 = upsert_entity(&conn, "global", &new_entity_helper("alpha-node"))?;
1540        let e2 = upsert_entity(&conn, "global", &new_entity_helper("beta-node"))?;
1541        link_memory_entity(&conn, memory_id, e1)?;
1542        link_memory_entity(&conn, memory_id, e2)?;
1543        let rel = NewRelationship {
1544            source: "alpha-node".to_string(),
1545            target: "beta-node".to_string(),
1546            relation: "related".to_string(),
1547            strength: 0.5,
1548            description: None,
1549        };
1550        let rel_id = upsert_relationship(&conn, "global", e1, e2, &rel)?;
1551        link_memory_relationship(&conn, memory_id, rel_id)?;
1552
1553        let (e_removed, r_removed) = clear_memory_graph_bindings(&conn, memory_id)?;
1554        assert_eq!(e_removed, 2);
1555        assert_eq!(r_removed, 1);
1556
1557        let ent_left: i64 = conn.query_row(
1558            "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1559            params![memory_id],
1560            |r| r.get(0),
1561        )?;
1562        let rel_left: i64 = conn.query_row(
1563            "SELECT COUNT(*) FROM memory_relationships WHERE memory_id = ?1",
1564            params![memory_id],
1565            |r| r.get(0),
1566        )?;
1567        assert_eq!(ent_left, 0);
1568        assert_eq!(rel_left, 0);
1569        Ok(())
1570    }
1571}