Skip to main content

mongreldb_kit_core/
schema.rs

1//! Language-neutral schema model for MongrelDB Kit.
2//!
3//! A [`Schema`] is a collection of [`Table`]s. Each table has [`Column`]s,
4//! indexes, unique constraints, foreign keys, and check constraints.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9/// Storage/application types supported by Kit columns.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ColumnType {
13    Bool,
14    Int8,
15    Int16,
16    Int32,
17    Int64,
18    Float32,
19    Float64,
20    Text,
21    Bytes,
22    Json,
23    Date,
24    DateTime,
25    TimestampNanos,
26    Date64,
27    Time64,
28    Interval,
29    Decimal128,
30    /// RFC 4122 UUID (16 bytes, big-endian).
31    Uuid,
32    /// Native JSON value (stored as typed bytes).
33    JsonNative,
34    /// Variable-length array of homogeneous values.
35    Array,
36    /// A dense float32 vector for nearest-neighbour (ANN) search. The dimension
37    /// is carried on the column as `embedding_dim`.
38    Embedding,
39    /// A learned-sparse (SPLADE-style) weighted token vector, stored as a
40    /// `[[token_id, weight], ...]` list, for sparse retrieval.
41    Sparse,
42}
43
44/// How a default value is produced when a row omits a column.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DefaultKind {
48    /// A fixed JSON value written literally.
49    Static(serde_json::Value),
50    /// The current timestamp as an ISO-8601 string.
51    Now,
52    /// A fresh UUIDv4 string.
53    Uuid,
54    /// The next value from a named sequence.
55    Sequence(String),
56    /// A user-defined default registered by name (resolved at runtime).
57    CustomName(String),
58}
59
60/// Where dense embedding values for a column originate.
61///
62/// Mirrors `mongreldb_core::EmbeddingSource` in a language-neutral shape
63/// (string paths, serde-tagged). Omitting this on a column means
64/// application-supplied vectors (the engine default). Transactional generation
65/// uses a portable [`GeneratedEmbeddingSpec`] plus a process-local provider.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum EmbeddingSpecNormalization {
69    #[default]
70    None,
71    L2,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum EmbeddingWriteFailurePolicy {
77    #[default]
78    AbortWrite,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct GeneratedEmbeddingSpec {
83    pub provider_id: String,
84    pub model_id: String,
85    pub model_version: String,
86    pub source_columns: Vec<u32>,
87    pub input_template: String,
88    pub dimension: u32,
89    pub normalization: EmbeddingSpecNormalization,
90    pub failure_policy: EmbeddingWriteFailurePolicy,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95pub enum EmbeddingSource {
96    /// Application writes float vectors directly (default).
97    SuppliedByApplication,
98    /// Local on-disk model; a provider registered under `model_id` runs inference.
99    LocalModel {
100        /// Filesystem path to model weights / tokenizer bundle.
101        model_path: String,
102        /// Stable model identity (registry key and ANN generation stamp).
103        model_id: String,
104    },
105    /// Portable model identity resolved from node-local provider configuration.
106    ConfiguredModel {
107        provider_id: String,
108        model_id: String,
109        model_version: String,
110    },
111    /// Named provider registered on the process (`provider` registry key).
112    GeneratedColumn {
113        /// Registry key of the provider.
114        provider: String,
115    },
116    /// Transactionally materialized embedding from source columns.
117    GeneratedColumnSpec { spec: GeneratedEmbeddingSpec },
118}
119
120/// A column definition.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct Column {
123    /// Stable column identifier. IDs must be unique within a table.
124    pub id: u32,
125    /// Logical column name.
126    pub name: String,
127    /// Physical storage type.
128    pub storage_type: ColumnType,
129    /// Application-facing type (often the same as `storage_type`).
130    pub application_type: ColumnType,
131    /// Whether the column may contain `null`.
132    pub nullable: bool,
133    /// Whether this column is part of the primary key.
134    pub primary_key: bool,
135    /// Optional default value generator.
136    pub default: Option<DefaultKind>,
137    /// Whether the value is generated on every mutation.
138    pub generated: bool,
139    /// Permitted string values, if any.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub enum_values: Option<Vec<String>>,
142    /// Minimum numeric value.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub min: Option<f64>,
145    /// Maximum numeric value.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub max: Option<f64>,
148    /// Minimum string/bytes length.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub min_length: Option<usize>,
151    /// Maximum string/bytes length.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub max_length: Option<usize>,
154    /// Regular expression a `text` value must match, stored as its source pattern.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub regex: Option<String>,
157    /// An optional check expression name for runtime evaluation.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub check_expr: Option<String>,
160    /// Vector dimension for an `Embedding` column (required for ANN).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub embedding_dim: Option<u32>,
163    /// How embedding values are produced. Only meaningful for
164    /// [`ColumnType::Embedding`]. `None` = application-supplied (engine default).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub embedding_source: Option<EmbeddingSource>,
167    /// Encrypt this column's page payload at rest (requires an encrypted db).
168    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169    pub encrypted: bool,
170    /// Encrypt the column but keep it queryable via deterministic equality
171    /// tokens / order-preserving encoding (requires an encrypted db).
172    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173    pub encrypted_indexable: bool,
174}
175
176impl Column {
177    /// Convenience constructor for the common case.
178    pub fn new(id: u32, name: impl Into<String>, storage_type: ColumnType) -> Self {
179        Self {
180            id,
181            name: name.into(),
182            storage_type,
183            application_type: storage_type,
184            nullable: false,
185            primary_key: false,
186            default: None,
187            generated: false,
188            enum_values: None,
189            min: None,
190            max: None,
191            min_length: None,
192            max_length: None,
193            regex: None,
194            check_expr: None,
195            embedding_dim: None,
196            embedding_source: None,
197            encrypted: false,
198            encrypted_indexable: false,
199        }
200    }
201}
202
203/// The kind of secondary index the Kit declares on a column.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum IndexKind {
207    /// Equality / `IN` acceleration (the default).
208    #[default]
209    Bitmap,
210    /// FM-index substring search (`contains(col, needle)` pushes to `FmContains`).
211    Fm,
212    /// HNSW approximate-nearest-neighbour index for `Embedding` columns.
213    Ann,
214    /// SPLADE-style learned-sparse retrieval index for `Sparse` columns.
215    Sparse,
216    /// MinHash/LSH set-similarity index over a JSON-array set column
217    /// (accelerates `set_similarity`).
218    MinHash,
219    /// Learned zonemap (PGM) index for ordered range predicates on numeric /
220    /// timestamp columns. Accelerates `Range`/`RangeF64` conditions.
221    LearnedRange,
222}
223
224/// ANN representation and distance metric.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum AnnQuantization {
228    /// One sign bit per component, ranked by Hamming distance.
229    #[default]
230    BinarySign,
231    /// Full `f32` vectors, ranked by cosine distance.
232    Dense,
233}
234
235/// An index on one or more columns.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
237pub struct Index {
238    pub name: String,
239    pub columns: Vec<String>,
240    pub unique: bool,
241    /// Index kind; defaults to `Bitmap` so pre-existing schemas deserialize
242    /// unchanged.
243    #[serde(default)]
244    pub kind: IndexKind,
245    /// ANN representation. Ignored for non-ANN indexes.
246    #[serde(default)]
247    pub ann_quantization: AnnQuantization,
248    /// Optional SQL predicate for a partial index.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub predicate: Option<String>,
251    /// HNSW graph degree. Engine default when omitted.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub ann_m: Option<usize>,
254    /// HNSW construction search width. Engine default when omitted.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub ann_ef_construction: Option<usize>,
257    /// HNSW query search width. Engine default when omitted.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub ann_ef_search: Option<usize>,
260    /// MinHash permutation count. Engine default when omitted.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub minhash_permutations: Option<usize>,
263    /// MinHash LSH band count. Engine default when omitted.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub minhash_bands: Option<usize>,
266    /// Learned-range error bound. Engine default when omitted.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub learned_range_epsilon: Option<usize>,
269}
270
271/// A uniqueness constraint over one or more columns.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct UniqueConstraint {
274    pub name: String,
275    pub columns: Vec<String>,
276}
277
278/// A foreign-key reference from child columns to parent columns.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ForeignKey {
281    pub name: String,
282    pub columns: Vec<String>,
283    pub references_table: String,
284    pub references_columns: Vec<String>,
285    #[serde(default)]
286    pub on_delete: ForeignKeyAction,
287}
288
289/// Action taken when a referenced parent row is deleted.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum ForeignKeyAction {
293    #[default]
294    Restrict,
295    Cascade,
296    SetNull,
297}
298
299/// A named table-level check constraint.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct CheckConstraint {
302    pub name: String,
303    pub expr: String,
304}
305
306/// A monotonic sequence allocator.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct Sequence {
309    pub name: String,
310    pub next_value: i64,
311}
312
313/// A table definition.
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315pub struct Table {
316    /// Stable table identifier. IDs must be unique within a schema.
317    pub id: u32,
318    pub name: String,
319    pub columns: Vec<Column>,
320    pub primary_key: Vec<String>,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub indexes: Vec<Index>,
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub foreign_keys: Vec<ForeignKey>,
325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
326    pub unique_constraints: Vec<UniqueConstraint>,
327    #[serde(default, skip_serializing_if = "Vec::is_empty")]
328    pub check_constraints: Vec<CheckConstraint>,
329}
330
331impl Table {
332    /// Find a column by name.
333    pub fn column(&self, name: &str) -> Option<&Column> {
334        self.columns.iter().find(|c| c.name == name)
335    }
336
337    /// Whether the named column is part of the primary key.
338    pub fn is_pk_column(&self, name: &str) -> bool {
339        self.primary_key.iter().any(|c| c == name)
340    }
341}
342
343/// Errors that can occur while constructing a [`Schema`].
344#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
345pub enum SchemaError {
346    #[error("duplicate table name \"{0}\"")]
347    DuplicateTableName(String),
348    #[error("duplicate table id {0}")]
349    DuplicateTableId(u32),
350    #[error("duplicate column name \"{1}\" in table \"{0}\"")]
351    DuplicateColumnName(String, String),
352    #[error("duplicate column id {1} in table \"{0}\"")]
353    DuplicateColumnId(String, u32),
354    #[error("primary key column \"{1}\" not found in table \"{0}\"")]
355    MissingPrimaryKeyColumn(String, String),
356    #[error("index \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
357    MissingIndexColumn(String, String, String),
358    #[error("unique constraint \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
359    MissingUniqueColumn(String, String, String),
360    #[error("foreign key \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
361    MissingForeignKeyColumn(String, String, String),
362    #[error("foreign key \"{1}\" references unknown table \"{2}\"")]
363    MissingReferencedTable(String, String, String),
364    #[error("foreign key \"{1}\" references unknown column \"{2}\" on table \"{3}\"")]
365    MissingReferencedColumn(String, String, String, String),
366    #[error(
367        "column \"{1}\" on table \"{0}\" sets embedding_source but is not an embedding column"
368    )]
369    EmbeddingSourceOnNonEmbedding(String, String),
370    #[error(
371        "embedding column \"{1}\" on table \"{0}\" with LocalModel/GeneratedColumn source requires embedding_dim > 0"
372    )]
373    EmbeddingSourceMissingDim(String, String),
374    #[error("generated embedding column \"{1}\" on table \"{0}\" is invalid: {2}")]
375    InvalidGeneratedEmbeddingSpec(String, String, String),
376}
377
378/// A validated collection of tables.
379#[derive(Debug, Clone, PartialEq, Serialize)]
380pub struct Schema {
381    pub tables: Vec<Table>,
382    by_name: HashMap<String, usize>,
383    by_id: HashMap<u32, usize>,
384}
385
386impl<'de> serde::Deserialize<'de> for Schema {
387    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388    where
389        D: serde::Deserializer<'de>,
390    {
391        #[derive(serde::Deserialize)]
392        struct SchemaHelper {
393            tables: Vec<Table>,
394        }
395        let helper = SchemaHelper::deserialize(deserializer)?;
396        Schema::new(helper.tables).map_err(serde::de::Error::custom)
397    }
398}
399
400/// A unique index also enforces uniqueness (guard-backed), matching SQL where a
401/// UNIQUE index is a UNIQUE constraint. Synthesize a constraint for each unique
402/// index unless an existing (or already-synthesized) unique constraint already
403/// covers exactly the same columns. Mirrors the TypeScript kit's `table()`.
404fn synthesize_unique_from_indexes(table: &mut Table) {
405    let mut synthesized: Vec<UniqueConstraint> = Vec::new();
406    for idx in &table.indexes {
407        if !idx.unique {
408            continue;
409        }
410        let covered = table
411            .unique_constraints
412            .iter()
413            .chain(synthesized.iter())
414            .any(|u| u.columns == idx.columns);
415        if !covered {
416            synthesized.push(UniqueConstraint {
417                name: idx.name.clone(),
418                columns: idx.columns.clone(),
419            });
420        }
421    }
422    table.unique_constraints.extend(synthesized);
423}
424
425impl Schema {
426    /// Build and validate a schema from a list of tables.
427    pub fn new(mut tables: Vec<Table>) -> Result<Self, SchemaError> {
428        for table in &mut tables {
429            synthesize_unique_from_indexes(table);
430        }
431
432        let mut by_name = HashMap::with_capacity(tables.len());
433        let mut by_id = HashMap::with_capacity(tables.len());
434
435        for (idx, table) in tables.iter().enumerate() {
436            if by_name.contains_key(&table.name) {
437                return Err(SchemaError::DuplicateTableName(table.name.clone()));
438            }
439            if by_id.contains_key(&table.id) {
440                return Err(SchemaError::DuplicateTableId(table.id));
441            }
442            by_name.insert(table.name.clone(), idx);
443            by_id.insert(table.id, idx);
444        }
445
446        for table in &tables {
447            Self::validate_table(table, &by_name)?;
448        }
449
450        Ok(Self {
451            tables,
452            by_name,
453            by_id,
454        })
455    }
456
457    fn validate_table(
458        table: &Table,
459        table_names: &HashMap<String, usize>,
460    ) -> Result<(), SchemaError> {
461        let mut column_names = HashMap::with_capacity(table.columns.len());
462        let mut column_ids = HashMap::with_capacity(table.columns.len());
463
464        for col in &table.columns {
465            if column_names.contains_key(&col.name) {
466                return Err(SchemaError::DuplicateColumnName(
467                    table.name.clone(),
468                    col.name.clone(),
469                ));
470            }
471            if column_ids.contains_key(&col.id) {
472                return Err(SchemaError::DuplicateColumnId(table.name.clone(), col.id));
473            }
474            if col.embedding_source.is_some() && col.storage_type != ColumnType::Embedding {
475                return Err(SchemaError::EmbeddingSourceOnNonEmbedding(
476                    table.name.clone(),
477                    col.name.clone(),
478                ));
479            }
480            if matches!(
481                col.embedding_source,
482                Some(
483                    EmbeddingSource::LocalModel { .. }
484                        | EmbeddingSource::ConfiguredModel { .. }
485                        | EmbeddingSource::GeneratedColumn { .. }
486                        | EmbeddingSource::GeneratedColumnSpec { .. }
487                )
488            ) && col.embedding_dim.unwrap_or(0) == 0
489            {
490                return Err(SchemaError::EmbeddingSourceMissingDim(
491                    table.name.clone(),
492                    col.name.clone(),
493                ));
494            }
495            column_names.insert(col.name.clone(), col.id);
496            column_ids.insert(col.id, col.name.clone());
497        }
498
499        for col in &table.columns {
500            if let Some(EmbeddingSource::ConfiguredModel {
501                provider_id,
502                model_id,
503                model_version,
504            }) = col.embedding_source.as_ref()
505            {
506                if provider_id.is_empty() || model_id.is_empty() || model_version.is_empty() {
507                    return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
508                        table.name.clone(),
509                        col.name.clone(),
510                        "provider, model, and version are required".into(),
511                    ));
512                }
513            }
514        }
515
516        for col in &table.columns {
517            let Some(EmbeddingSource::GeneratedColumnSpec { spec }) = col.embedding_source.as_ref()
518            else {
519                continue;
520            };
521            if spec.provider_id.is_empty()
522                || spec.model_id.is_empty()
523                || spec.model_version.is_empty()
524                || spec.source_columns.is_empty()
525            {
526                return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
527                    table.name.clone(),
528                    col.name.clone(),
529                    "provider, model, version, and source columns are required".into(),
530                ));
531            }
532            if col.embedding_dim != Some(spec.dimension) {
533                return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
534                    table.name.clone(),
535                    col.name.clone(),
536                    "spec dimension must match embedding_dim".into(),
537                ));
538            }
539            let mut seen = HashSet::new();
540            if spec.source_columns.iter().any(|source_id| {
541                *source_id == col.id
542                    || !seen.insert(*source_id)
543                    || !column_ids.contains_key(source_id)
544            }) {
545                return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
546                    table.name.clone(),
547                    col.name.clone(),
548                    "source columns must exist, be unique, and exclude the target".into(),
549                ));
550            }
551        }
552
553        for pk in &table.primary_key {
554            if !column_names.contains_key(pk) {
555                return Err(SchemaError::MissingPrimaryKeyColumn(
556                    table.name.clone(),
557                    pk.clone(),
558                ));
559            }
560        }
561
562        for idx in &table.indexes {
563            for col in &idx.columns {
564                if !column_names.contains_key(col) {
565                    return Err(SchemaError::MissingIndexColumn(
566                        table.name.clone(),
567                        idx.name.clone(),
568                        col.clone(),
569                    ));
570                }
571            }
572        }
573
574        for uq in &table.unique_constraints {
575            for col in &uq.columns {
576                if !column_names.contains_key(col) {
577                    return Err(SchemaError::MissingUniqueColumn(
578                        table.name.clone(),
579                        uq.name.clone(),
580                        col.clone(),
581                    ));
582                }
583            }
584        }
585
586        for fk in &table.foreign_keys {
587            for col in &fk.columns {
588                if !column_names.contains_key(col) {
589                    return Err(SchemaError::MissingForeignKeyColumn(
590                        table.name.clone(),
591                        fk.name.clone(),
592                        col.clone(),
593                    ));
594                }
595            }
596            if !table_names.contains_key(&fk.references_table) {
597                return Err(SchemaError::MissingReferencedTable(
598                    table.name.clone(),
599                    fk.name.clone(),
600                    fk.references_table.clone(),
601                ));
602            }
603        }
604
605        Ok(())
606    }
607
608    /// Look up a table by name.
609    pub fn table(&self, name: &str) -> Option<&Table> {
610        self.by_name.get(name).map(|&idx| &self.tables[idx])
611    }
612
613    /// Look up a table by stable id.
614    pub fn table_by_id(&self, id: u32) -> Option<&Table> {
615        self.by_id.get(&id).map(|&idx| &self.tables[idx])
616    }
617
618    /// Whether the schema contains a table with the given name.
619    pub fn has_table(&self, name: &str) -> bool {
620        self.by_name.contains_key(name)
621    }
622
623    /// Rename a table in place, keeping the `by_name` index in sync. Returns
624    /// `false` if `from` is absent or `to` is already in use (no change made).
625    /// Does *not* retarget foreign keys — callers that need that should do it
626    /// before/after on the tables they own.
627    pub fn rename_table(&mut self, from: &str, to: &str) -> bool {
628        if from == to {
629            return self.has_table(from);
630        }
631        if !self.has_table(from) || self.has_table(to) {
632            return false;
633        }
634        let idx = *self.by_name.get(from).unwrap();
635        self.tables[idx].name = to.to_string();
636        self.by_name.remove(from);
637        self.by_name.insert(to.to_string(), idx);
638        true
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn make_table(name: &str, id: u32) -> Table {
647        Table {
648            id,
649            name: name.into(),
650            columns: vec![Column::new(1, "id", ColumnType::Int64)],
651            primary_key: vec!["id".into()],
652            indexes: vec![],
653            foreign_keys: vec![],
654            unique_constraints: vec![],
655            check_constraints: vec![],
656        }
657    }
658
659    #[test]
660    fn schema_rejects_duplicate_table_name() {
661        let err = Schema::new(vec![make_table("a", 1), make_table("a", 2)]).unwrap_err();
662        assert!(matches!(err, SchemaError::DuplicateTableName(n) if n == "a"));
663    }
664
665    #[test]
666    fn schema_rejects_duplicate_table_id() {
667        let err = Schema::new(vec![make_table("a", 1), make_table("b", 1)]).unwrap_err();
668        assert!(matches!(err, SchemaError::DuplicateTableId(1)));
669    }
670
671    #[test]
672    fn schema_rejects_missing_pk_column() {
673        let t = Table {
674            id: 1,
675            name: "t".into(),
676            columns: vec![Column::new(1, "x", ColumnType::Text)],
677            primary_key: vec!["id".into()],
678            indexes: vec![],
679            foreign_keys: vec![],
680            unique_constraints: vec![],
681            check_constraints: vec![],
682        };
683        let err = Schema::new(vec![t]).unwrap_err();
684        assert!(matches!(err, SchemaError::MissingPrimaryKeyColumn(_, _)));
685    }
686
687    #[test]
688    fn unique_index_synthesizes_unique_constraint() {
689        let schema = Schema::new(vec![Table {
690            id: 1,
691            name: "users".into(),
692            columns: vec![
693                Column::new(1, "id", ColumnType::Int64),
694                Column::new(2, "email", ColumnType::Text),
695                Column::new(3, "handle", ColumnType::Text),
696            ],
697            primary_key: vec!["id".into()],
698            indexes: vec![
699                Index {
700                    name: "idx_email".into(),
701                    columns: vec!["email".into()],
702                    unique: true,
703                    kind: Default::default(),
704                    ann_quantization: Default::default(),
705                    ..Default::default()
706                },
707                // A non-unique index must NOT synthesize a constraint.
708                Index {
709                    name: "idx_handle".into(),
710                    columns: vec!["handle".into()],
711                    unique: false,
712                    kind: Default::default(),
713                    ann_quantization: Default::default(),
714                    ..Default::default()
715                },
716            ],
717            foreign_keys: vec![],
718            unique_constraints: vec![],
719            check_constraints: vec![],
720        }])
721        .unwrap();
722        let table = schema.table("users").unwrap();
723        assert_eq!(table.unique_constraints.len(), 1);
724        assert_eq!(
725            table.unique_constraints[0].columns,
726            vec!["email".to_string()]
727        );
728    }
729
730    #[test]
731    fn unique_index_does_not_duplicate_existing_constraint() {
732        let schema = Schema::new(vec![Table {
733            id: 1,
734            name: "users".into(),
735            columns: vec![
736                Column::new(1, "id", ColumnType::Int64),
737                Column::new(2, "email", ColumnType::Text),
738            ],
739            primary_key: vec!["id".into()],
740            indexes: vec![Index {
741                name: "idx_email".into(),
742                columns: vec!["email".into()],
743                unique: true,
744                kind: Default::default(),
745                ann_quantization: Default::default(),
746                ..Default::default()
747            }],
748            foreign_keys: vec![],
749            unique_constraints: vec![UniqueConstraint {
750                name: "uq_email".into(),
751                columns: vec!["email".into()],
752            }],
753            check_constraints: vec![],
754        }])
755        .unwrap();
756        // The pre-existing constraint already covers `email`; no synthesis.
757        let table = schema.table("users").unwrap();
758        assert_eq!(table.unique_constraints.len(), 1);
759        assert_eq!(table.unique_constraints[0].name, "uq_email");
760    }
761
762    #[test]
763    fn schema_roundtrips_json() {
764        let schema = Schema::new(vec![Table {
765            id: 1,
766            name: "users".into(),
767            columns: vec![
768                Column::new(1, "id", ColumnType::Int64),
769                Column {
770                    nullable: true,
771                    ..Column::new(2, "email", ColumnType::Text)
772                },
773            ],
774            primary_key: vec!["id".into()],
775            indexes: vec![Index {
776                name: "idx_email".into(),
777                columns: vec!["email".into()],
778                unique: true,
779                kind: Default::default(),
780                ann_quantization: Default::default(),
781                ..Default::default()
782            }],
783            foreign_keys: vec![],
784            unique_constraints: vec![],
785            check_constraints: vec![CheckConstraint {
786                name: "chk_id_positive".into(),
787                expr: "id > 0".into(),
788            }],
789        }])
790        .unwrap();
791
792        let json = serde_json::to_string(&schema).unwrap();
793        let decoded: Schema = serde_json::from_str(&json).unwrap();
794        assert_eq!(decoded.tables.len(), 1);
795        assert_eq!(decoded.table("users").unwrap().columns.len(), 2);
796    }
797
798    #[test]
799    fn dense_ann_quantization_roundtrips_and_old_json_defaults_binary() {
800        let dense = Index {
801            name: "idx_embedding".into(),
802            columns: vec!["embedding".into()],
803            unique: false,
804            kind: IndexKind::Ann,
805            ann_quantization: AnnQuantization::Dense,
806            predicate: Some("embedding IS NOT NULL".into()),
807            ann_m: Some(24),
808            ann_ef_construction: Some(96),
809            ann_ef_search: Some(48),
810            ..Default::default()
811        };
812        let json = serde_json::to_value(&dense).unwrap();
813        assert_eq!(json["ann_quantization"], "dense");
814        assert_eq!(json["ann_m"], 24);
815        assert_eq!(json["predicate"], "embedding IS NOT NULL");
816        assert_eq!(
817            serde_json::from_value::<Index>(json)
818                .unwrap()
819                .ann_quantization,
820            AnnQuantization::Dense
821        );
822
823        let old: Index = serde_json::from_value(serde_json::json!({
824            "name": "idx_embedding",
825            "columns": ["embedding"],
826            "unique": false,
827            "kind": "ann"
828        }))
829        .unwrap();
830        assert_eq!(old.ann_quantization, AnnQuantization::BinarySign);
831    }
832
833    #[test]
834    fn embedding_source_roundtrips_json() {
835        let mut emb = Column::new(2, "vec", ColumnType::Embedding);
836        emb.embedding_dim = Some(4);
837        emb.embedding_source = Some(EmbeddingSource::LocalModel {
838            model_path: "/models/kit-mini".into(),
839            model_id: "kit-mini".into(),
840        });
841        let schema = Schema::new(vec![Table {
842            id: 1,
843            name: "docs".into(),
844            columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
845            primary_key: vec!["id".into()],
846            indexes: vec![],
847            foreign_keys: vec![],
848            unique_constraints: vec![],
849            check_constraints: vec![],
850        }])
851        .unwrap();
852        let json = serde_json::to_string(&schema).unwrap();
853        assert!(json.contains("local_model"));
854        assert!(json.contains("kit-mini"));
855        let decoded: Schema = serde_json::from_str(&json).unwrap();
856        let col = decoded.table("docs").unwrap().column("vec").unwrap();
857        assert_eq!(
858            col.embedding_source,
859            Some(EmbeddingSource::LocalModel {
860                model_path: "/models/kit-mini".into(),
861                model_id: "kit-mini".into(),
862            })
863        );
864    }
865
866    #[test]
867    fn embedding_source_rejected_on_non_embedding() {
868        let mut col = Column::new(2, "name", ColumnType::Text);
869        col.embedding_source = Some(EmbeddingSource::SuppliedByApplication);
870        let err = Schema::new(vec![Table {
871            id: 1,
872            name: "t".into(),
873            columns: vec![Column::new(1, "id", ColumnType::Int64), col],
874            primary_key: vec!["id".into()],
875            indexes: vec![],
876            foreign_keys: vec![],
877            unique_constraints: vec![],
878            check_constraints: vec![],
879        }])
880        .unwrap_err();
881        assert!(matches!(
882            err,
883            SchemaError::EmbeddingSourceOnNonEmbedding(_, _)
884        ));
885    }
886
887    #[test]
888    fn generated_embedding_requires_dim() {
889        let mut emb = Column::new(2, "vec", ColumnType::Embedding);
890        emb.embedding_source = Some(EmbeddingSource::GeneratedColumn {
891            provider: "local-test".into(),
892        });
893        let err = Schema::new(vec![Table {
894            id: 1,
895            name: "t".into(),
896            columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
897            primary_key: vec!["id".into()],
898            indexes: vec![],
899            foreign_keys: vec![],
900            unique_constraints: vec![],
901            check_constraints: vec![],
902        }])
903        .unwrap_err();
904        assert!(matches!(err, SchemaError::EmbeddingSourceMissingDim(_, _)));
905    }
906
907    #[test]
908    fn generated_embedding_spec_roundtrips_and_validates_sources() {
909        let mut emb = Column::new(3, "vec", ColumnType::Embedding);
910        emb.embedding_dim = Some(4);
911        emb.embedding_source = Some(EmbeddingSource::GeneratedColumnSpec {
912            spec: GeneratedEmbeddingSpec {
913                provider_id: "provider".into(),
914                model_id: "model".into(),
915                model_version: "1".into(),
916                source_columns: vec![2],
917                input_template: "{body}".into(),
918                dimension: 4,
919                normalization: EmbeddingSpecNormalization::None,
920                failure_policy: EmbeddingWriteFailurePolicy::AbortWrite,
921            },
922        });
923        let schema = Schema::new(vec![Table {
924            id: 1,
925            name: "docs".into(),
926            columns: vec![
927                Column::new(1, "id", ColumnType::Int64),
928                Column::new(2, "body", ColumnType::Text),
929                emb,
930            ],
931            primary_key: vec!["id".into()],
932            indexes: vec![],
933            foreign_keys: vec![],
934            unique_constraints: vec![],
935            check_constraints: vec![],
936        }])
937        .unwrap();
938        let json = serde_json::to_string(&schema).unwrap();
939        assert!(json.contains("generated_column_spec"));
940        assert_eq!(serde_json::from_str::<Schema>(&json).unwrap(), schema);
941    }
942}