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