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}
145
146/// An index on one or more columns.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Index {
149    pub name: String,
150    pub columns: Vec<String>,
151    pub unique: bool,
152    /// Index kind; defaults to `Bitmap` so pre-existing schemas deserialize
153    /// unchanged.
154    #[serde(default)]
155    pub kind: IndexKind,
156}
157
158/// A uniqueness constraint over one or more columns.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct UniqueConstraint {
161    pub name: String,
162    pub columns: Vec<String>,
163}
164
165/// A foreign-key reference from child columns to parent columns.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ForeignKey {
168    pub name: String,
169    pub columns: Vec<String>,
170    pub references_table: String,
171    pub references_columns: Vec<String>,
172    #[serde(default)]
173    pub on_delete: ForeignKeyAction,
174}
175
176/// Action taken when a referenced parent row is deleted.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ForeignKeyAction {
180    #[default]
181    Restrict,
182    Cascade,
183    SetNull,
184}
185
186/// A named table-level check constraint.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct CheckConstraint {
189    pub name: String,
190    pub expr: String,
191}
192
193/// A monotonic sequence allocator.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct Sequence {
196    pub name: String,
197    pub next_value: i64,
198}
199
200/// A table definition.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct Table {
203    /// Stable table identifier. IDs must be unique within a schema.
204    pub id: u32,
205    pub name: String,
206    pub columns: Vec<Column>,
207    pub primary_key: Vec<String>,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub indexes: Vec<Index>,
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub foreign_keys: Vec<ForeignKey>,
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub unique_constraints: Vec<UniqueConstraint>,
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub check_constraints: Vec<CheckConstraint>,
216}
217
218impl Table {
219    /// Find a column by name.
220    pub fn column(&self, name: &str) -> Option<&Column> {
221        self.columns.iter().find(|c| c.name == name)
222    }
223
224    /// Whether the named column is part of the primary key.
225    pub fn is_pk_column(&self, name: &str) -> bool {
226        self.primary_key.iter().any(|c| c == name)
227    }
228}
229
230/// Errors that can occur while constructing a [`Schema`].
231#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
232pub enum SchemaError {
233    #[error("duplicate table name \"{0}\"")]
234    DuplicateTableName(String),
235    #[error("duplicate table id {0}")]
236    DuplicateTableId(u32),
237    #[error("duplicate column name \"{1}\" in table \"{0}\"")]
238    DuplicateColumnName(String, String),
239    #[error("duplicate column id {1} in table \"{0}\"")]
240    DuplicateColumnId(String, u32),
241    #[error("primary key column \"{1}\" not found in table \"{0}\"")]
242    MissingPrimaryKeyColumn(String, String),
243    #[error("index \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
244    MissingIndexColumn(String, String, String),
245    #[error("unique constraint \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
246    MissingUniqueColumn(String, String, String),
247    #[error("foreign key \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
248    MissingForeignKeyColumn(String, String, String),
249    #[error("foreign key \"{1}\" references unknown table \"{2}\"")]
250    MissingReferencedTable(String, String, String),
251    #[error("foreign key \"{1}\" references unknown column \"{2}\" on table \"{3}\"")]
252    MissingReferencedColumn(String, String, String, String),
253}
254
255/// A validated collection of tables.
256#[derive(Debug, Clone, PartialEq, Serialize)]
257pub struct Schema {
258    pub tables: Vec<Table>,
259    by_name: HashMap<String, usize>,
260    by_id: HashMap<u32, usize>,
261}
262
263impl<'de> serde::Deserialize<'de> for Schema {
264    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265    where
266        D: serde::Deserializer<'de>,
267    {
268        #[derive(serde::Deserialize)]
269        struct SchemaHelper {
270            tables: Vec<Table>,
271        }
272        let helper = SchemaHelper::deserialize(deserializer)?;
273        Schema::new(helper.tables).map_err(serde::de::Error::custom)
274    }
275}
276
277/// A unique index also enforces uniqueness (guard-backed), matching SQL where a
278/// UNIQUE index is a UNIQUE constraint. Synthesize a constraint for each unique
279/// index unless an existing (or already-synthesized) unique constraint already
280/// covers exactly the same columns. Mirrors the TypeScript kit's `table()`.
281fn synthesize_unique_from_indexes(table: &mut Table) {
282    let mut synthesized: Vec<UniqueConstraint> = Vec::new();
283    for idx in &table.indexes {
284        if !idx.unique {
285            continue;
286        }
287        let covered = table
288            .unique_constraints
289            .iter()
290            .chain(synthesized.iter())
291            .any(|u| u.columns == idx.columns);
292        if !covered {
293            synthesized.push(UniqueConstraint {
294                name: idx.name.clone(),
295                columns: idx.columns.clone(),
296            });
297        }
298    }
299    table.unique_constraints.extend(synthesized);
300}
301
302impl Schema {
303    /// Build and validate a schema from a list of tables.
304    pub fn new(mut tables: Vec<Table>) -> Result<Self, SchemaError> {
305        for table in &mut tables {
306            synthesize_unique_from_indexes(table);
307        }
308
309        let mut by_name = HashMap::with_capacity(tables.len());
310        let mut by_id = HashMap::with_capacity(tables.len());
311
312        for (idx, table) in tables.iter().enumerate() {
313            if by_name.contains_key(&table.name) {
314                return Err(SchemaError::DuplicateTableName(table.name.clone()));
315            }
316            if by_id.contains_key(&table.id) {
317                return Err(SchemaError::DuplicateTableId(table.id));
318            }
319            by_name.insert(table.name.clone(), idx);
320            by_id.insert(table.id, idx);
321        }
322
323        for table in &tables {
324            Self::validate_table(table, &by_name)?;
325        }
326
327        Ok(Self {
328            tables,
329            by_name,
330            by_id,
331        })
332    }
333
334    fn validate_table(
335        table: &Table,
336        table_names: &HashMap<String, usize>,
337    ) -> Result<(), SchemaError> {
338        let mut column_names = HashMap::with_capacity(table.columns.len());
339        let mut column_ids = HashMap::with_capacity(table.columns.len());
340
341        for col in &table.columns {
342            if column_names.contains_key(&col.name) {
343                return Err(SchemaError::DuplicateColumnName(
344                    table.name.clone(),
345                    col.name.clone(),
346                ));
347            }
348            if column_ids.contains_key(&col.id) {
349                return Err(SchemaError::DuplicateColumnId(table.name.clone(), col.id));
350            }
351            column_names.insert(col.name.clone(), col.id);
352            column_ids.insert(col.id, col.name.clone());
353        }
354
355        for pk in &table.primary_key {
356            if !column_names.contains_key(pk) {
357                return Err(SchemaError::MissingPrimaryKeyColumn(
358                    table.name.clone(),
359                    pk.clone(),
360                ));
361            }
362        }
363
364        for idx in &table.indexes {
365            for col in &idx.columns {
366                if !column_names.contains_key(col) {
367                    return Err(SchemaError::MissingIndexColumn(
368                        table.name.clone(),
369                        idx.name.clone(),
370                        col.clone(),
371                    ));
372                }
373            }
374        }
375
376        for uq in &table.unique_constraints {
377            for col in &uq.columns {
378                if !column_names.contains_key(col) {
379                    return Err(SchemaError::MissingUniqueColumn(
380                        table.name.clone(),
381                        uq.name.clone(),
382                        col.clone(),
383                    ));
384                }
385            }
386        }
387
388        for fk in &table.foreign_keys {
389            for col in &fk.columns {
390                if !column_names.contains_key(col) {
391                    return Err(SchemaError::MissingForeignKeyColumn(
392                        table.name.clone(),
393                        fk.name.clone(),
394                        col.clone(),
395                    ));
396                }
397            }
398            if !table_names.contains_key(&fk.references_table) {
399                return Err(SchemaError::MissingReferencedTable(
400                    table.name.clone(),
401                    fk.name.clone(),
402                    fk.references_table.clone(),
403                ));
404            }
405        }
406
407        Ok(())
408    }
409
410    /// Look up a table by name.
411    pub fn table(&self, name: &str) -> Option<&Table> {
412        self.by_name.get(name).map(|&idx| &self.tables[idx])
413    }
414
415    /// Look up a table by stable id.
416    pub fn table_by_id(&self, id: u32) -> Option<&Table> {
417        self.by_id.get(&id).map(|&idx| &self.tables[idx])
418    }
419
420    /// Whether the schema contains a table with the given name.
421    pub fn has_table(&self, name: &str) -> bool {
422        self.by_name.contains_key(name)
423    }
424
425    /// Rename a table in place, keeping the `by_name` index in sync. Returns
426    /// `false` if `from` is absent or `to` is already in use (no change made).
427    /// Does *not* retarget foreign keys — callers that need that should do it
428    /// before/after on the tables they own.
429    pub fn rename_table(&mut self, from: &str, to: &str) -> bool {
430        if from == to {
431            return self.has_table(from);
432        }
433        if !self.has_table(from) || self.has_table(to) {
434            return false;
435        }
436        let idx = *self.by_name.get(from).unwrap();
437        self.tables[idx].name = to.to_string();
438        self.by_name.remove(from);
439        self.by_name.insert(to.to_string(), idx);
440        true
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn make_table(name: &str, id: u32) -> Table {
449        Table {
450            id,
451            name: name.into(),
452            columns: vec![Column::new(1, "id", ColumnType::Int64)],
453            primary_key: vec!["id".into()],
454            indexes: vec![],
455            foreign_keys: vec![],
456            unique_constraints: vec![],
457            check_constraints: vec![],
458        }
459    }
460
461    #[test]
462    fn schema_rejects_duplicate_table_name() {
463        let err = Schema::new(vec![make_table("a", 1), make_table("a", 2)]).unwrap_err();
464        assert!(matches!(err, SchemaError::DuplicateTableName(n) if n == "a"));
465    }
466
467    #[test]
468    fn schema_rejects_duplicate_table_id() {
469        let err = Schema::new(vec![make_table("a", 1), make_table("b", 1)]).unwrap_err();
470        assert!(matches!(err, SchemaError::DuplicateTableId(1)));
471    }
472
473    #[test]
474    fn schema_rejects_missing_pk_column() {
475        let t = Table {
476            id: 1,
477            name: "t".into(),
478            columns: vec![Column::new(1, "x", ColumnType::Text)],
479            primary_key: vec!["id".into()],
480            indexes: vec![],
481            foreign_keys: vec![],
482            unique_constraints: vec![],
483            check_constraints: vec![],
484        };
485        let err = Schema::new(vec![t]).unwrap_err();
486        assert!(matches!(err, SchemaError::MissingPrimaryKeyColumn(_, _)));
487    }
488
489    #[test]
490    fn unique_index_synthesizes_unique_constraint() {
491        let schema = Schema::new(vec![Table {
492            id: 1,
493            name: "users".into(),
494            columns: vec![
495                Column::new(1, "id", ColumnType::Int64),
496                Column::new(2, "email", ColumnType::Text),
497                Column::new(3, "handle", ColumnType::Text),
498            ],
499            primary_key: vec!["id".into()],
500            indexes: vec![
501                Index {
502                    name: "idx_email".into(),
503                    columns: vec!["email".into()],
504                    unique: true,
505                    kind: Default::default(),
506                },
507                // A non-unique index must NOT synthesize a constraint.
508                Index {
509                    name: "idx_handle".into(),
510                    columns: vec!["handle".into()],
511                    unique: false,
512                    kind: Default::default(),
513                },
514            ],
515            foreign_keys: vec![],
516            unique_constraints: vec![],
517            check_constraints: vec![],
518        }])
519        .unwrap();
520        let table = schema.table("users").unwrap();
521        assert_eq!(table.unique_constraints.len(), 1);
522        assert_eq!(
523            table.unique_constraints[0].columns,
524            vec!["email".to_string()]
525        );
526    }
527
528    #[test]
529    fn unique_index_does_not_duplicate_existing_constraint() {
530        let schema = Schema::new(vec![Table {
531            id: 1,
532            name: "users".into(),
533            columns: vec![
534                Column::new(1, "id", ColumnType::Int64),
535                Column::new(2, "email", ColumnType::Text),
536            ],
537            primary_key: vec!["id".into()],
538            indexes: vec![Index {
539                name: "idx_email".into(),
540                columns: vec!["email".into()],
541                unique: true,
542                kind: Default::default(),
543            }],
544            foreign_keys: vec![],
545            unique_constraints: vec![UniqueConstraint {
546                name: "uq_email".into(),
547                columns: vec!["email".into()],
548            }],
549            check_constraints: vec![],
550        }])
551        .unwrap();
552        // The pre-existing constraint already covers `email`; no synthesis.
553        let table = schema.table("users").unwrap();
554        assert_eq!(table.unique_constraints.len(), 1);
555        assert_eq!(table.unique_constraints[0].name, "uq_email");
556    }
557
558    #[test]
559    fn schema_roundtrips_json() {
560        let schema = Schema::new(vec![Table {
561            id: 1,
562            name: "users".into(),
563            columns: vec![
564                Column::new(1, "id", ColumnType::Int64),
565                Column {
566                    nullable: true,
567                    ..Column::new(2, "email", ColumnType::Text)
568                },
569            ],
570            primary_key: vec!["id".into()],
571            indexes: vec![Index {
572                name: "idx_email".into(),
573                columns: vec!["email".into()],
574                unique: true,
575                kind: Default::default(),
576            }],
577            foreign_keys: vec![],
578            unique_constraints: vec![],
579            check_constraints: vec![CheckConstraint {
580                name: "chk_id_positive".into(),
581                expr: "id > 0".into(),
582            }],
583        }])
584        .unwrap();
585
586        let json = serde_json::to_string(&schema).unwrap();
587        let decoded: Schema = serde_json::from_str(&json).unwrap();
588        assert_eq!(decoded.tables.len(), 1);
589        assert_eq!(decoded.table("users").unwrap().columns.len(), 2);
590    }
591}