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