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