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