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