Skip to main content

sqlite_graphrag/storage/
entities.rs

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