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