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;
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    /// A dense float32 vector for nearest-neighbour (ANN) search. The dimension
27    /// is carried on the column as `embedding_dim`.
28    Embedding,
29    /// A learned-sparse (SPLADE-style) weighted token vector, stored as a
30    /// `[[token_id, weight], ...]` list, for sparse retrieval.
31    Sparse,
32}
33
34/// How a default value is produced when a row omits a column.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum DefaultKind {
38    /// A fixed JSON value written literally.
39    Static(serde_json::Value),
40    /// The current timestamp as an ISO-8601 string.
41    Now,
42    /// A fresh UUIDv4 string.
43    Uuid,
44    /// The next value from a named sequence.
45    Sequence(String),
46    /// A user-defined default registered by name (resolved at runtime).
47    CustomName(String),
48}
49
50/// A column definition.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct Column {
53    /// Stable column identifier. IDs must be unique within a table.
54    pub id: u32,
55    /// Logical column name.
56    pub name: String,
57    /// Physical storage type.
58    pub storage_type: ColumnType,
59    /// Application-facing type (often the same as `storage_type`).
60    pub application_type: ColumnType,
61    /// Whether the column may contain `null`.
62    pub nullable: bool,
63    /// Whether this column is part of the primary key.
64    pub primary_key: bool,
65    /// Optional default value generator.
66    pub default: Option<DefaultKind>,
67    /// Whether the value is generated on every mutation.
68    pub generated: bool,
69    /// Permitted string values, if any.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub enum_values: Option<Vec<String>>,
72    /// Minimum numeric value.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub min: Option<f64>,
75    /// Maximum numeric value.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub max: Option<f64>,
78    /// Minimum string/bytes length.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub min_length: Option<usize>,
81    /// Maximum string/bytes length.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub max_length: Option<usize>,
84    /// Regular expression a `text` value must match, stored as its source pattern.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub regex: Option<String>,
87    /// An optional check expression name for runtime evaluation.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub check_expr: Option<String>,
90    /// Vector dimension for an `Embedding` column (required for ANN).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub embedding_dim: Option<u32>,
93    /// Encrypt this column's page payload at rest (requires an encrypted db).
94    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95    pub encrypted: bool,
96    /// Encrypt the column but keep it queryable via deterministic equality
97    /// tokens / order-preserving encoding (requires an encrypted db).
98    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99    pub encrypted_indexable: bool,
100}
101
102impl Column {
103    /// Convenience constructor for the common case.
104    pub fn new(id: u32, name: impl Into<String>, storage_type: ColumnType) -> Self {
105        Self {
106            id,
107            name: name.into(),
108            storage_type,
109            application_type: storage_type,
110            nullable: false,
111            primary_key: false,
112            default: None,
113            generated: false,
114            enum_values: None,
115            min: None,
116            max: None,
117            min_length: None,
118            max_length: None,
119            regex: None,
120            check_expr: None,
121            embedding_dim: None,
122            encrypted: false,
123            encrypted_indexable: false,
124        }
125    }
126}
127
128/// The kind of secondary index the Kit declares on a column.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum IndexKind {
132    /// Equality / `IN` acceleration (the default).
133    #[default]
134    Bitmap,
135    /// FM-index substring search (`contains(col, needle)` pushes to `FmContains`).
136    Fm,
137    /// HNSW approximate-nearest-neighbour index for `Embedding` columns.
138    Ann,
139    /// SPLADE-style learned-sparse retrieval index for `Sparse` columns.
140    Sparse,
141    /// MinHash/LSH set-similarity index over a JSON-array set column
142    /// (accelerates `set_similarity`).
143    MinHash,
144    /// Learned zonemap (PGM) index for ordered range predicates on numeric /
145    /// timestamp columns. Accelerates `Range`/`RangeF64` conditions.
146    LearnedRange,
147}
148
149/// An index on one or more columns.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Index {
152    pub name: String,
153    pub columns: Vec<String>,
154    pub unique: bool,
155    /// Index kind; defaults to `Bitmap` so pre-existing schemas deserialize
156    /// unchanged.
157    #[serde(default)]
158    pub kind: IndexKind,
159}
160
161/// A uniqueness constraint over one or more columns.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct UniqueConstraint {
164    pub name: String,
165    pub columns: Vec<String>,
166}
167
168/// A foreign-key reference from child columns to parent columns.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ForeignKey {
171    pub name: String,
172    pub columns: Vec<String>,
173    pub references_table: String,
174    pub references_columns: Vec<String>,
175    #[serde(default)]
176    pub on_delete: ForeignKeyAction,
177}
178
179/// Action taken when a referenced parent row is deleted.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum ForeignKeyAction {
183    #[default]
184    Restrict,
185    Cascade,
186    SetNull,
187}
188
189/// A named table-level check constraint.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct CheckConstraint {
192    pub name: String,
193    pub expr: String,
194}
195
196/// A monotonic sequence allocator.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct Sequence {
199    pub name: String,
200    pub next_value: i64,
201}
202
203/// A table definition.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct Table {
206    /// Stable table identifier. IDs must be unique within a schema.
207    pub id: u32,
208    pub name: String,
209    pub columns: Vec<Column>,
210    pub primary_key: Vec<String>,
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub indexes: Vec<Index>,
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub foreign_keys: Vec<ForeignKey>,
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    pub unique_constraints: Vec<UniqueConstraint>,
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub check_constraints: Vec<CheckConstraint>,
219}
220
221impl Table {
222    /// Find a column by name.
223    pub fn column(&self, name: &str) -> Option<&Column> {
224        self.columns.iter().find(|c| c.name == name)
225    }
226
227    /// Whether the named column is part of the primary key.
228    pub fn is_pk_column(&self, name: &str) -> bool {
229        self.primary_key.iter().any(|c| c == name)
230    }
231}
232
233/// Errors that can occur while constructing a [`Schema`].
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum SchemaError {
236    #[error("duplicate table name \"{0}\"")]
237    DuplicateTableName(String),
238    #[error("duplicate table id {0}")]
239    DuplicateTableId(u32),
240    #[error("duplicate column name \"{1}\" in table \"{0}\"")]
241    DuplicateColumnName(String, String),
242    #[error("duplicate column id {1} in table \"{0}\"")]
243    DuplicateColumnId(String, u32),
244    #[error("primary key column \"{1}\" not found in table \"{0}\"")]
245    MissingPrimaryKeyColumn(String, String),
246    #[error("index \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
247    MissingIndexColumn(String, String, String),
248    #[error("unique constraint \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
249    MissingUniqueColumn(String, String, String),
250    #[error("foreign key \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
251    MissingForeignKeyColumn(String, String, String),
252    #[error("foreign key \"{1}\" references unknown table \"{2}\"")]
253    MissingReferencedTable(String, String, String),
254    #[error("foreign key \"{1}\" references unknown column \"{2}\" on table \"{3}\"")]
255    MissingReferencedColumn(String, String, String, String),
256}
257
258/// A validated collection of tables.
259#[derive(Debug, Clone, PartialEq, Serialize)]
260pub struct Schema {
261    pub tables: Vec<Table>,
262    by_name: HashMap<String, usize>,
263    by_id: HashMap<u32, usize>,
264}
265
266impl<'de> serde::Deserialize<'de> for Schema {
267    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
268    where
269        D: serde::Deserializer<'de>,
270    {
271        #[derive(serde::Deserialize)]
272        struct SchemaHelper {
273            tables: Vec<Table>,
274        }
275        let helper = SchemaHelper::deserialize(deserializer)?;
276        Schema::new(helper.tables).map_err(serde::de::Error::custom)
277    }
278}
279
280/// A unique index also enforces uniqueness (guard-backed), matching SQL where a
281/// UNIQUE index is a UNIQUE constraint. Synthesize a constraint for each unique
282/// index unless an existing (or already-synthesized) unique constraint already
283/// covers exactly the same columns. Mirrors the TypeScript kit's `table()`.
284fn synthesize_unique_from_indexes(table: &mut Table) {
285    let mut synthesized: Vec<UniqueConstraint> = Vec::new();
286    for idx in &table.indexes {
287        if !idx.unique {
288            continue;
289        }
290        let covered = table
291            .unique_constraints
292            .iter()
293            .chain(synthesized.iter())
294            .any(|u| u.columns == idx.columns);
295        if !covered {
296            synthesized.push(UniqueConstraint {
297                name: idx.name.clone(),
298                columns: idx.columns.clone(),
299            });
300        }
301    }
302    table.unique_constraints.extend(synthesized);
303}
304
305impl Schema {
306    /// Build and validate a schema from a list of tables.
307    pub fn new(mut tables: Vec<Table>) -> Result<Self, SchemaError> {
308        for table in &mut tables {
309            synthesize_unique_from_indexes(table);
310        }
311
312        let mut by_name = HashMap::with_capacity(tables.len());
313        let mut by_id = HashMap::with_capacity(tables.len());
314
315        for (idx, table) in tables.iter().enumerate() {
316            if by_name.contains_key(&table.name) {
317                return Err(SchemaError::DuplicateTableName(table.name.clone()));
318            }
319            if by_id.contains_key(&table.id) {
320                return Err(SchemaError::DuplicateTableId(table.id));
321            }
322            by_name.insert(table.name.clone(), idx);
323            by_id.insert(table.id, idx);
324        }
325
326        for table in &tables {
327            Self::validate_table(table, &by_name)?;
328        }
329
330        Ok(Self {
331            tables,
332            by_name,
333            by_id,
334        })
335    }
336
337    fn validate_table(
338        table: &Table,
339        table_names: &HashMap<String, usize>,
340    ) -> Result<(), SchemaError> {
341        let mut column_names = HashMap::with_capacity(table.columns.len());
342        let mut column_ids = HashMap::with_capacity(table.columns.len());
343
344        for col in &table.columns {
345            if column_names.contains_key(&col.name) {
346                return Err(SchemaError::DuplicateColumnName(
347                    table.name.clone(),
348                    col.name.clone(),
349                ));
350            }
351            if column_ids.contains_key(&col.id) {
352                return Err(SchemaError::DuplicateColumnId(table.name.clone(), col.id));
353            }
354            column_names.insert(col.name.clone(), col.id);
355            column_ids.insert(col.id, col.name.clone());
356        }
357
358        for pk in &table.primary_key {
359            if !column_names.contains_key(pk) {
360                return Err(SchemaError::MissingPrimaryKeyColumn(
361                    table.name.clone(),
362                    pk.clone(),
363                ));
364            }
365        }
366
367        for idx in &table.indexes {
368            for col in &idx.columns {
369                if !column_names.contains_key(col) {
370                    return Err(SchemaError::MissingIndexColumn(
371                        table.name.clone(),
372                        idx.name.clone(),
373                        col.clone(),
374                    ));
375                }
376            }
377        }
378
379        for uq in &table.unique_constraints {
380            for col in &uq.columns {
381                if !column_names.contains_key(col) {
382                    return Err(SchemaError::MissingUniqueColumn(
383                        table.name.clone(),
384                        uq.name.clone(),
385                        col.clone(),
386                    ));
387                }
388            }
389        }
390
391        for fk in &table.foreign_keys {
392            for col in &fk.columns {
393                if !column_names.contains_key(col) {
394                    return Err(SchemaError::MissingForeignKeyColumn(
395                        table.name.clone(),
396                        fk.name.clone(),
397                        col.clone(),
398                    ));
399                }
400            }
401            if !table_names.contains_key(&fk.references_table) {
402                return Err(SchemaError::MissingReferencedTable(
403                    table.name.clone(),
404                    fk.name.clone(),
405                    fk.references_table.clone(),
406                ));
407            }
408        }
409
410        Ok(())
411    }
412
413    /// Look up a table by name.
414    pub fn table(&self, name: &str) -> Option<&Table> {
415        self.by_name.get(name).map(|&idx| &self.tables[idx])
416    }
417
418    /// Look up a table by stable id.
419    pub fn table_by_id(&self, id: u32) -> Option<&Table> {
420        self.by_id.get(&id).map(|&idx| &self.tables[idx])
421    }
422
423    /// Whether the schema contains a table with the given name.
424    pub fn has_table(&self, name: &str) -> bool {
425        self.by_name.contains_key(name)
426    }
427
428    /// Rename a table in place, keeping the `by_name` index in sync. Returns
429    /// `false` if `from` is absent or `to` is already in use (no change made).
430    /// Does *not* retarget foreign keys — callers that need that should do it
431    /// before/after on the tables they own.
432    pub fn rename_table(&mut self, from: &str, to: &str) -> bool {
433        if from == to {
434            return self.has_table(from);
435        }
436        if !self.has_table(from) || self.has_table(to) {
437            return false;
438        }
439        let idx = *self.by_name.get(from).unwrap();
440        self.tables[idx].name = to.to_string();
441        self.by_name.remove(from);
442        self.by_name.insert(to.to_string(), idx);
443        true
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn make_table(name: &str, id: u32) -> Table {
452        Table {
453            id,
454            name: name.into(),
455            columns: vec![Column::new(1, "id", ColumnType::Int64)],
456            primary_key: vec!["id".into()],
457            indexes: vec![],
458            foreign_keys: vec![],
459            unique_constraints: vec![],
460            check_constraints: vec![],
461        }
462    }
463
464    #[test]
465    fn schema_rejects_duplicate_table_name() {
466        let err = Schema::new(vec![make_table("a", 1), make_table("a", 2)]).unwrap_err();
467        assert!(matches!(err, SchemaError::DuplicateTableName(n) if n == "a"));
468    }
469
470    #[test]
471    fn schema_rejects_duplicate_table_id() {
472        let err = Schema::new(vec![make_table("a", 1), make_table("b", 1)]).unwrap_err();
473        assert!(matches!(err, SchemaError::DuplicateTableId(1)));
474    }
475
476    #[test]
477    fn schema_rejects_missing_pk_column() {
478        let t = Table {
479            id: 1,
480            name: "t".into(),
481            columns: vec![Column::new(1, "x", ColumnType::Text)],
482            primary_key: vec!["id".into()],
483            indexes: vec![],
484            foreign_keys: vec![],
485            unique_constraints: vec![],
486            check_constraints: vec![],
487        };
488        let err = Schema::new(vec![t]).unwrap_err();
489        assert!(matches!(err, SchemaError::MissingPrimaryKeyColumn(_, _)));
490    }
491
492    #[test]
493    fn unique_index_synthesizes_unique_constraint() {
494        let schema = Schema::new(vec![Table {
495            id: 1,
496            name: "users".into(),
497            columns: vec![
498                Column::new(1, "id", ColumnType::Int64),
499                Column::new(2, "email", ColumnType::Text),
500                Column::new(3, "handle", ColumnType::Text),
501            ],
502            primary_key: vec!["id".into()],
503            indexes: vec![
504                Index {
505                    name: "idx_email".into(),
506                    columns: vec!["email".into()],
507                    unique: true,
508                    kind: Default::default(),
509                },
510                // A non-unique index must NOT synthesize a constraint.
511                Index {
512                    name: "idx_handle".into(),
513                    columns: vec!["handle".into()],
514                    unique: false,
515                    kind: Default::default(),
516                },
517            ],
518            foreign_keys: vec![],
519            unique_constraints: vec![],
520            check_constraints: vec![],
521        }])
522        .unwrap();
523        let table = schema.table("users").unwrap();
524        assert_eq!(table.unique_constraints.len(), 1);
525        assert_eq!(
526            table.unique_constraints[0].columns,
527            vec!["email".to_string()]
528        );
529    }
530
531    #[test]
532    fn unique_index_does_not_duplicate_existing_constraint() {
533        let schema = Schema::new(vec![Table {
534            id: 1,
535            name: "users".into(),
536            columns: vec![
537                Column::new(1, "id", ColumnType::Int64),
538                Column::new(2, "email", ColumnType::Text),
539            ],
540            primary_key: vec!["id".into()],
541            indexes: vec![Index {
542                name: "idx_email".into(),
543                columns: vec!["email".into()],
544                unique: true,
545                kind: Default::default(),
546            }],
547            foreign_keys: vec![],
548            unique_constraints: vec![UniqueConstraint {
549                name: "uq_email".into(),
550                columns: vec!["email".into()],
551            }],
552            check_constraints: vec![],
553        }])
554        .unwrap();
555        // The pre-existing constraint already covers `email`; no synthesis.
556        let table = schema.table("users").unwrap();
557        assert_eq!(table.unique_constraints.len(), 1);
558        assert_eq!(table.unique_constraints[0].name, "uq_email");
559    }
560
561    #[test]
562    fn schema_roundtrips_json() {
563        let schema = Schema::new(vec![Table {
564            id: 1,
565            name: "users".into(),
566            columns: vec![
567                Column::new(1, "id", ColumnType::Int64),
568                Column {
569                    nullable: true,
570                    ..Column::new(2, "email", ColumnType::Text)
571                },
572            ],
573            primary_key: vec!["id".into()],
574            indexes: vec![Index {
575                name: "idx_email".into(),
576                columns: vec!["email".into()],
577                unique: true,
578                kind: Default::default(),
579            }],
580            foreign_keys: vec![],
581            unique_constraints: vec![],
582            check_constraints: vec![CheckConstraint {
583                name: "chk_id_positive".into(),
584                expr: "id > 0".into(),
585            }],
586        }])
587        .unwrap();
588
589        let json = serde_json::to_string(&schema).unwrap();
590        let decoded: Schema = serde_json::from_str(&json).unwrap();
591        assert_eq!(decoded.tables.len(), 1);
592        assert_eq!(decoded.table("users").unwrap().columns.len(), 2);
593    }
594}