Skip to main content

sqlite_graphrag/storage/entities/
mod.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
7mod merge;
8
9pub use merge::{
10    clear_memory_graph_bindings, count_relationships_by_relation, create_or_fetch_relationship,
11    delete_entities_by_ids, delete_relationship_by_id, delete_relationships_by_ids,
12    delete_relationships_by_relation, find_dangling_relationship_ids, find_entity_id,
13    find_orphan_entity_ids, find_relationship, increment_degree, link_memory_entity,
14    link_memory_relationship, list_entity_names_by_relation, recalculate_degree,
15    unlink_memory_entity, RelationshipRow,
16};
17
18use crate::embedder::f32_to_bytes;
19use crate::entity_type::normalize_entity_type;
20use crate::errors::AppError;
21use crate::parsers::normalize_entity_name;
22use crate::storage::utils::with_busy_retry;
23use rusqlite::{params, Connection};
24use serde::{Deserialize, Serialize};
25
26/// Input payload used to upsert a single entity.
27///
28/// `name` is normalized to kebab-case by the caller. `description` is
29/// optional and preserved across upserts when the new value is `None`.
30#[derive(Debug, Serialize, Deserialize, Clone)]
31#[serde(deny_unknown_fields)]
32pub struct NewEntity {
33    /// Name of this item.
34    pub name: String,
35    /// Entity type label, stored as the caller wrote it.
36    ///
37    /// v1.2.8: plain `String` rather than a closed enum. Deserialization no
38    /// longer folds unknown labels onto `concept` — that fold destroyed the
39    /// caller's word before any layer could see it. Shape is normalised at the
40    /// write boundary by [`normalize_entity_type`], which is where a refusal
41    /// can still be reported; membership in the canonical set is advisory and
42    /// only enforced under `--strict-entity-types`.
43    #[serde(alias = "type")]
44    pub entity_type: String,
45    /// Human-readable description.
46    pub description: Option<String>,
47}
48
49/// Input payload used to upsert a typed relationship between entities.
50///
51/// `strength` must lie within `[0.0, 1.0]` and is mapped to the `weight`
52/// column of the `relationships` table.
53#[derive(Debug, Serialize, Deserialize, Clone)]
54#[serde(deny_unknown_fields)]
55pub struct NewRelationship {
56    /// Source side of the relationship.
57    #[serde(alias = "from")]
58    pub source: String,
59    /// Target side of the relationship.
60    #[serde(alias = "to")]
61    pub target: String,
62    /// Relationship type.
63    #[serde(alias = "type")]
64    pub relation: String,
65    /// Relationship strength in `[0.0, 1.0]`. Defaults to
66    /// [`crate::constants::DEFAULT_RELATION_WEIGHT`] (0.5) when omitted from
67    /// graph-stdin / graph-file JSON (GAP-CLI-GRAPH-01).
68    #[serde(alias = "weight", default = "default_relationship_strength")]
69    pub strength: f64,
70    /// Human-readable description.
71    pub description: Option<String>,
72}
73
74fn default_relationship_strength() -> f64 {
75    crate::constants::DEFAULT_RELATION_WEIGHT
76}
77
78/// Validates entity name against quality rules.
79///
80/// Rejects names with newlines, names shorter than 2 characters, and
81/// ALL_CAPS abbreviations of 4 characters or fewer (common NER noise).
82///
83/// # Errors
84///
85/// Returns `Err(AppError::Validation)` when the name violates any rule.
86pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
87    if name.len() < 2 {
88        return Err(AppError::Validation(
89            crate::i18n::validation::entity_name_too_short(name),
90        ));
91    }
92    if name.contains('\n') || name.contains('\r') {
93        return Err(AppError::Validation(
94            "entity name must not contain newline characters".to_string(),
95        ));
96    }
97    // v1.1.05 Bug 5: pure digit names are almost always accidental entity IDs
98    // passed as `--from`/`--to` instead of names (or via `--from-id`/`--to-id`).
99    // Reject them so `--create-missing` cannot pollute the graph with ghost nodes.
100    if name.chars().all(|c| c.is_ascii_digit()) {
101        return Err(AppError::Validation(
102            crate::i18n::validation::entity_name_purely_numeric(name),
103        ));
104    }
105    if name.len() <= 4
106        && name
107            .chars()
108            .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
109    {
110        return Err(AppError::Validation(
111            crate::i18n::validation::entity_name_all_caps_noise(name),
112        ));
113    }
114    Ok(())
115}
116
117/// Fuzzy match candidate returned by [`suggest_entity_names`] / [`resolve_entity_fuzzy`].
118#[derive(Debug, Clone)]
119pub struct FuzzyEntityMatch {
120    /// Unique identifier.
121    pub id: i64,
122    /// Name of this item.
123    pub name: String,
124    /// Similarity in `[0.0, 1.0]` (1.0 = exact).
125    pub score: f64,
126}
127
128/// Score how well `query` matches a canonical entity `name`.
129///
130/// Prefers exact, prefix-of-kebab, first-token equality, then Jaro-Winkler
131/// (rapidfuzz) so short nicknames like `alice` rank `alice-martins-souza`
132/// highly.
133pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
134    let q = query.trim().to_ascii_lowercase();
135    let n = name.trim().to_ascii_lowercase();
136    if q.is_empty() || n.is_empty() {
137        return 0.0;
138    }
139    if q == n {
140        return 1.0;
141    }
142    // Prefix of a kebab/snake name: "alice" ↔ "alice-martins-souza"
143    if n.starts_with(&q) {
144        let rest = &n[q.len()..];
145        if rest.is_empty()
146            || rest.starts_with('-')
147            || rest.starts_with('_')
148            || rest.starts_with(' ')
149        {
150            return 0.95;
151        }
152        // Longer shared prefix still strong
153        return 0.88;
154    }
155    if q.starts_with(&n) && n.len() >= 3 {
156        return 0.80;
157    }
158    let first_token = n
159        .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
160        .next()
161        .unwrap_or(n.as_str());
162    if first_token == q {
163        return 0.92;
164    }
165    if n.contains(&q) && q.len() >= 3 {
166        return 0.82;
167    }
168    rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
169}
170
171/// Rank entity names in `namespace` by fuzzy similarity to `query`.
172///
173/// Returns up to `limit` candidates with score ≥ `min_score`, sorted by score
174/// descending (ties break alphabetically).
175pub fn suggest_entity_names(
176    conn: &Connection,
177    namespace: &str,
178    query: &str,
179    limit: usize,
180    min_score: f64,
181) -> Result<Vec<FuzzyEntityMatch>, AppError> {
182    let entities = list_entities(conn, Some(namespace))?;
183    let mut scored: Vec<FuzzyEntityMatch> = entities
184        .into_iter()
185        .filter_map(|e| {
186            let score = entity_name_similarity(query, &e.name);
187            if score >= min_score {
188                Some(FuzzyEntityMatch {
189                    id: e.id,
190                    name: e.name,
191                    score,
192                })
193            } else {
194                None
195            }
196        })
197        .collect();
198    scored.sort_by(|a, b| {
199        b.score
200            .partial_cmp(&a.score)
201            .unwrap_or(std::cmp::Ordering::Equal)
202            .then_with(|| a.name.cmp(&b.name))
203    });
204    scored.truncate(limit.max(1));
205    Ok(scored)
206}
207
208/// Resolve an entity by exact name, then optionally by fuzzy match.
209///
210/// * Exact match always wins.
211/// * When `auto_fuzzy` is true and exactly one candidate scores ≥ `min_score`
212///   (or the top candidate is ≥ 0.90 and beats the runner-up by ≥ 0.05),
213///   that candidate is returned with a stderr warning.
214/// * When no auto-resolution is possible, returns `Ok(None)` after the caller
215///   can surface suggestions via [`suggest_entity_names`].
216pub fn resolve_entity_fuzzy(
217    conn: &Connection,
218    namespace: &str,
219    name: &str,
220    auto_fuzzy: bool,
221) -> Result<Option<(i64, String, bool)>, AppError> {
222    if let Some(id) = find_entity_id(conn, namespace, name)? {
223        return Ok(Some((id, name.to_string(), false)));
224    }
225    // Case-insensitive exact via list (names are normalized kebab, but callers
226    // may pass mixed case).
227    let normalized = crate::parsers::normalize_entity_name(name);
228    if normalized != name {
229        if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
230            return Ok(Some((id, normalized, false)));
231        }
232    }
233    if !auto_fuzzy {
234        return Ok(None);
235    }
236    let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
237    if suggestions.is_empty() {
238        return Ok(None);
239    }
240    let top = &suggestions[0];
241    let clear_winner =
242        top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
243    let single_strong = suggestions.len() == 1 && top.score >= 0.85;
244    if clear_winner || single_strong {
245        tracing::warn!(
246            target: "entities",
247            query = %name,
248            resolved = %top.name,
249            score = top.score,
250            "fuzzy entity resolution: exact match failed; using best candidate"
251        );
252        return Ok(Some((top.id, top.name.clone(), true)));
253    }
254    Ok(None)
255}
256
257/// Build a NotFound message that includes fuzzy suggestions when available.
258pub fn entity_not_found_with_suggestions(
259    conn: &Connection,
260    namespace: &str,
261    name: &str,
262) -> AppError {
263    let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
264    if suggestions.is_empty() {
265        return AppError::NotFound(
266            crate::i18n::validation::entity_named_not_found_in_namespace(name, namespace),
267        );
268    }
269    let list: Vec<String> = suggestions
270        .iter()
271        .map(|s| format!("{} (score={:.2})", s.name, s.score))
272        .collect();
273    AppError::NotFound(
274        crate::i18n::validation::entity_named_not_found_with_suggestions(
275            name,
276            namespace,
277            &list.join(", "),
278        ),
279    )
280}
281
282/// Upserts an entity and returns its primary key.
283///
284/// Uses `ON CONFLICT(namespace, name)` to keep one row per entity within a
285/// namespace, refreshing `type` and `description` opportunistically.
286///
287/// # Errors
288///
289/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
290pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
291    // Step 1: validate the original name — catches ALL_CAPS short noise (NER artefacts),
292    // newlines, and names shorter than 2 characters before any transformation.
293    validate_entity_name(&e.name)?;
294    // Step 2: normalize to kebab-case ASCII (NFKD, lowercase, spaces/underscores → hyphens).
295    let normalized_name = normalize_entity_name(&e.name);
296    // Step 3: guard post-normalization length — a valid original could collapse to < 2 chars
297    // (e.g. a single accented character that strips entirely).
298    if normalized_name.chars().count() < 2 {
299        return Err(AppError::Validation(
300            crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
301        ));
302    }
303    // Step 4: normalise the type label's SHAPE. Membership is not checked here
304    // — V017 opened the vocabulary, so an unknown label is stored as written.
305    let normalized_type = normalize_entity_type(&e.entity_type)?;
306    conn.execute(
307        "INSERT INTO entities (namespace, name, type, description)
308         VALUES (?1, ?2, ?3, ?4)
309         ON CONFLICT(namespace, name) DO UPDATE SET
310           type        = excluded.type,
311           description = COALESCE(excluded.description, entities.description),
312           updated_at  = unixepoch()",
313        params![namespace, normalized_name, normalized_type, e.description],
314    )?;
315    let id: i64 = conn.query_row(
316        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
317        params![namespace, normalized_name],
318        |r| r.get(0),
319    )?;
320    Ok(id)
321}
322
323/// Upserts an entity WITHOUT overwriting a type someone already committed to.
324///
325/// Same contract as [`upsert_entity`] except for the `type` column: a new row
326/// takes the caller's type, and an existing row keeps its own unless that type
327/// is the generic `concept`, in which case the caller may refine it.
328///
329/// This exists because [`upsert_entity`] writes `type = excluded.type`
330/// unconditionally and the LLM enrichment worker runs AFTER every write. A
331/// person declared as `person` in `remember --graph-stdin` was re-typed by
332/// whatever the model guessed minutes later, with the graph reporting a type
333/// nobody asked for and no envelope ever mentioning the change. Measured on a
334/// live corpus: an area of a company stored as `person`.
335///
336/// The rule is deliberately asymmetric. Human write paths — `remember`, `link`,
337/// `ingest`, `split_body` — keep calling [`upsert_entity`] and stay
338/// authoritative. Extraction calls this one, so it can still TYPE what nobody
339/// typed and can still refine `concept`, which since v1.2.8 means only "the
340/// caller supplied no type" rather than "the caller's label did not fit", and
341/// therefore still carries no commitment worth preserving.
342///
343/// # Errors
344///
345/// Returns `Err(AppError::Database)` on any `rusqlite` failure, and
346/// `Err(AppError::Validation)` on a name that fails [`validate_entity_name`].
347pub fn upsert_entity_preserving_type(
348    conn: &Connection,
349    namespace: &str,
350    e: &NewEntity,
351) -> Result<i64, AppError> {
352    validate_entity_name(&e.name)?;
353    let normalized_name = normalize_entity_name(&e.name);
354    if normalized_name.chars().count() < 2 {
355        return Err(AppError::Validation(
356            crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
357        ));
358    }
359    let normalized_type = normalize_entity_type(&e.entity_type)?;
360    conn.execute(
361        "INSERT INTO entities (namespace, name, type, description)
362         VALUES (?1, ?2, ?3, ?4)
363         ON CONFLICT(namespace, name) DO UPDATE SET
364           type        = CASE WHEN entities.type = 'concept'
365                              THEN excluded.type
366                              ELSE entities.type END,
367           description = COALESCE(excluded.description, entities.description),
368           updated_at  = unixepoch()",
369        params![namespace, normalized_name, normalized_type, e.description],
370    )?;
371    let id: i64 = conn.query_row(
372        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
373        params![namespace, normalized_name],
374        |r| r.get(0),
375    )?;
376    Ok(id)
377}
378
379/// Replaces the vector row for an entity in `entity_embeddings`.
380///
381/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
382/// table; cosine similarity is computed in pure Rust on demand. The
383/// `entity_type` and `name` arguments are accepted for API compatibility
384/// but are not stored — the entities table is the source of truth.
385///
386/// # Errors
387///
388/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
389pub fn upsert_entity_vec(
390    conn: &Connection,
391    entity_id: i64,
392    namespace: &str,
393    _entity_type: &str,
394    embedding: &[f32],
395    _name: &str,
396) -> Result<(), AppError> {
397    // v1.1.1 (P1): an empty vector means the embedding backend was skipped
398    // (`--llm-backend none` without OpenRouter). Writing an empty BLOB would
399    // hide the entity from the re-embed backfill scanner (the row exists but
400    // carries no vector), so skip the write and leave the entity scannable.
401    if embedding.is_empty() {
402        tracing::debug!(
403            entity_id,
404            "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
405        );
406        return Ok(());
407    }
408    let embedding_bytes = f32_to_bytes(embedding);
409    with_busy_retry(|| {
410        conn.execute(
411            "DELETE FROM entity_embeddings WHERE entity_id = ?1",
412            params![entity_id],
413        )?;
414        conn.execute(
415            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
416             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
417            params![
418                entity_id,
419                namespace,
420                &embedding_bytes,
421                "llm-headless",
422                crate::constants::SQLITE_GRAPHRAG_VERSION,
423                crate::constants::embedding_dim() as i64,
424            ],
425        )?;
426        Ok(())
427    })
428}
429
430/// Upserts a typed relationship between two entity ids.
431///
432/// Conflicts on `(source_id, target_id, relation)` refresh `weight` and
433/// preserve a non-null `description`. Returns the `rowid` of the stored row.
434///
435/// # Errors
436///
437/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
438pub fn upsert_relationship(
439    conn: &Connection,
440    namespace: &str,
441    source_id: i64,
442    target_id: i64,
443    rel: &NewRelationship,
444) -> Result<i64, AppError> {
445    // v1.2.8: canonicalised here for the same reason as
446    // `create_or_fetch_relationship` — the invariant belongs to the boundary
447    // that writes, not to each caller that remembers.
448    let relation = crate::parsers::map_to_canonical_relation(&rel.relation);
449    conn.execute(
450        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
451         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
452         ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
453           weight = excluded.weight,
454           description = COALESCE(excluded.description, relationships.description)",
455        params![
456            namespace,
457            source_id,
458            target_id,
459            relation,
460            rel.strength,
461            rel.description
462        ],
463    )?;
464    let id: i64 = conn.query_row(
465        "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
466        params![source_id, target_id, relation],
467        |r| r.get(0),
468    )?;
469    Ok(id)
470}
471
472/// Entity row with enough data for graph export/query.
473#[derive(Debug, Serialize, Clone)]
474pub struct EntityNode {
475    /// Unique identifier.
476    pub id: i64,
477    /// Name of this item.
478    pub name: String,
479    /// Namespace scope.
480    pub namespace: String,
481    /// Kind discriminator.
482    pub kind: String,
483    /// Stored description, `None` when NULL or empty (G-PR-7).
484    ///
485    /// Carried so `graph` can export it: `entities.description` had no bulk
486    /// read path in the CLI at all, which is what let a bad description-writing
487    /// policy run unnoticed over a six-figure entity count.
488    pub description: Option<String>,
489}
490
491/// Lists entities, filtering by namespace if provided.
492///
493/// # Errors
494///
495/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
496pub fn list_entities(
497    conn: &Connection,
498    namespace: Option<&str>,
499) -> Result<Vec<EntityNode>, AppError> {
500    if let Some(ns) = namespace {
501        let mut stmt = conn.prepare_cached(
502            "SELECT id, name, namespace, type, description FROM entities WHERE namespace = ?1 ORDER BY id",
503        )?;
504        let rows = stmt
505            .query_map(params![ns], |r| {
506                Ok(EntityNode {
507                    id: r.get(0)?,
508                    name: r.get(1)?,
509                    namespace: r.get(2)?,
510                    kind: r.get(3)?,
511                    description: r
512                        .get::<_, Option<String>>(4)?
513                        .filter(|d| !d.trim().is_empty()),
514                })
515            })?
516            .collect::<Result<Vec<_>, _>>()?;
517        Ok(rows)
518    } else {
519        let mut stmt = conn.prepare_cached(
520            "SELECT id, name, namespace, type, description FROM entities ORDER BY namespace, id",
521        )?;
522        let rows = stmt
523            .query_map([], |r| {
524                Ok(EntityNode {
525                    id: r.get(0)?,
526                    name: r.get(1)?,
527                    namespace: r.get(2)?,
528                    kind: r.get(3)?,
529                    description: r
530                        .get::<_, Option<String>>(4)?
531                        .filter(|d| !d.trim().is_empty()),
532                })
533            })?
534            .collect::<Result<Vec<_>, _>>()?;
535        Ok(rows)
536    }
537}
538
539/// Lists relations filtered by namespace (of source/target entities).
540///
541/// # Errors
542///
543/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
544pub fn list_relationships_by_namespace(
545    conn: &Connection,
546    namespace: Option<&str>,
547) -> Result<Vec<RelationshipRow>, AppError> {
548    if let Some(ns) = namespace {
549        let mut stmt = conn.prepare_cached(
550            "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
551             FROM relationships r
552             JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
553             JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
554             ORDER BY r.id",
555        )?;
556        let rows = stmt
557            .query_map(params![ns], |r| {
558                Ok(RelationshipRow {
559                    id: r.get(0)?,
560                    namespace: r.get(1)?,
561                    source_id: r.get(2)?,
562                    target_id: r.get(3)?,
563                    relation: r.get(4)?,
564                    weight: r.get(5)?,
565                    description: r.get(6)?,
566                })
567            })?
568            .collect::<Result<Vec<_>, _>>()?;
569        Ok(rows)
570    } else {
571        let mut stmt = conn.prepare_cached(
572            "SELECT id, namespace, source_id, target_id, relation, weight, description
573             FROM relationships ORDER BY id",
574        )?;
575        let rows = stmt
576            .query_map([], |r| {
577                Ok(RelationshipRow {
578                    id: r.get(0)?,
579                    namespace: r.get(1)?,
580                    source_id: r.get(2)?,
581                    target_id: r.get(3)?,
582                    relation: r.get(4)?,
583                    weight: r.get(5)?,
584                    description: r.get(6)?,
585                })
586            })?
587            .collect::<Result<Vec<_>, _>>()?;
588        Ok(rows)
589    }
590}
591
592/// Searches the `entity_embeddings` table for the k nearest neighbours
593/// using pure-Rust cosine similarity.
594///
595/// v1.0.76: sqlite-vec was removed. The full table scan + in-process
596/// cosine is O(N × D) per call. For namespaces with more than ~10k
597/// entities, the operator should rely on FTS5 (`hybrid-search`) for
598/// coarse filtering before reaching this function.
599///
600/// # Errors
601///
602/// - [`AppError::Database`] — SQLite query failure.
603/// - [`AppError::Embedding`] — invalid or mismatched embedding dimension.
604pub fn knn_search(
605    conn: &Connection,
606    embedding: &[f32],
607    namespace: &str,
608    k: usize,
609) -> Result<Vec<(i64, f32)>, AppError> {
610    if embedding.len() != crate::constants::embedding_dim() {
611        return Err(AppError::Embedding(
612            crate::i18n::validation::embedding_knn_search_dim_mismatch(
613                embedding.len(),
614                crate::constants::embedding_dim(),
615            ),
616        ));
617    }
618    let mut stmt = conn.prepare_cached(
619        "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
620    )?;
621    let mut scored: Vec<(i64, f32)> = stmt
622        .query_map(params![namespace], |r| {
623            let id: i64 = r.get(0)?;
624            let bytes: Vec<u8> = r.get(1)?;
625            Ok((id, bytes))
626        })?
627        .filter_map(|row| {
628            row.ok().and_then(|(id, bytes)| {
629                let stored = crate::embedder::bytes_to_f32(&bytes);
630                if stored.len() != embedding.len() {
631                    return None;
632                }
633                let score = crate::similarity::cosine_similarity(embedding, &stored);
634                Some((id, score))
635            })
636        })
637        .collect();
638    // `cosine_similarity` returns a value in [-1.0, 1.0]; 1.0 is the
639    // best match. Sort descending and truncate to `k`.
640    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
641    scored.truncate(k);
642    Ok(scored)
643}
644
645// GAP-SG-146: test modules named for what they exercise. `test_fixtures`
646// holds the schema bootstrap both halves used to duplicate.
647#[cfg(test)]
648#[path = "entity_crud_tests.rs"]
649mod crud_tests;
650#[cfg(test)]
651#[path = "entity_name_validation_tests.rs"]
652mod name_validation_tests;
653#[cfg(test)]
654#[path = "entity_relationship_tests.rs"]
655mod relationship_tests;
656#[cfg(test)]
657#[path = "entity_test_fixtures.rs"]
658mod test_fixtures;
659#[cfg(test)]
660#[path = "entity_vector_tests.rs"]
661mod vector_tests;