Skip to main content

mongreldb_kit/
schema.rs

1//! Schema/value conversion between the kit model and MongrelDB core.
2
3use crate::error::{KitError, Result};
4use mongreldb_core::constraint::{CheckConstraint as CoreCheckConstraint, CheckExpr, TableConstraints};
5use mongreldb_core::memtable::Value as CoreValue;
6use mongreldb_core::schema::{
7    ColumnDef, ColumnFlags, DefaultExpr, IndexDef, IndexKind, Schema as CoreSchema, TypeId,
8};
9use mongreldb_kit_core::schema::{
10    Column, ColumnType, DefaultKind, IndexKind as KitIndexKind, Table as KitTable,
11};
12use serde_json::{Map, Value};
13
14/// Convert a kit table to a core schema.
15///
16/// Engine 0.46.x accepts column-level `enum_values`, regex CHECK constraints,
17/// and `Static` / `Now` / `Uuid` defaults natively on `create_table`. This
18/// pass lowers the kit model into those engine-native shapes so the kit doesn't
19/// have to re-validate on every write.
20///
21/// Out of scope (kept kit-side per PLAN.md phase 4):
22/// - Table-level `check_constraints` — until the SQL CHECK compiler ships the
23///   kit cannot lower arbitrary boolean expressions to the engine's `CheckExpr`
24///   IR, so kit's per-write `validate_row` continues to enforce them.
25/// - `Column.check_expr` (named custom checks) — same reason; runtime
26///   registration only.
27/// - `DefaultKind::Sequence` / `DefaultKind::CustomName` — cannot cross the
28///   in-process boundary; resolved kit-side at write stage time.
29pub fn to_core_schema(table: &KitTable) -> CoreSchema {
30    let mut next_check_id: u16 = 1;
31    let mut core_checks: Vec<CoreCheckConstraint> = Vec::new();
32    let columns: Vec<ColumnDef> = table
33        .columns
34        .iter()
35        .map(|c| ColumnDef {
36            id: c.id as u16,
37            name: c.name.clone(),
38            ty: resolve_type(c),
39            flags: to_core_flags(table, c),
40            default_value: kit_default_to_core(&c.default, c.storage_type),
41        })
42        .collect();
43
44    for c in &table.columns {
45        if let Some(pattern) = &c.regex {
46            let id = next_check_id;
47            next_check_id = next_check_id.saturating_add(1);
48            core_checks.push(CoreCheckConstraint {
49                id,
50                name: format!("{}_regex", c.name),
51                expr: CheckExpr::Regex {
52                    col: c.id as u16,
53                    pattern: pattern.clone(),
54                    negated: false,
55                    case_insensitive: false,
56                    cached: std::sync::OnceLock::new(),
57                },
58            });
59        }
60    }
61
62    let mut indexes: Vec<IndexDef> = Vec::new();
63    for idx in &table.indexes {
64        let kind = match idx.kind {
65            KitIndexKind::Bitmap => IndexKind::Bitmap,
66            KitIndexKind::Fm => IndexKind::FmIndex,
67            KitIndexKind::Ann => IndexKind::Ann,
68            KitIndexKind::Sparse => IndexKind::Sparse,
69            KitIndexKind::MinHash => IndexKind::MinHash,
70            KitIndexKind::LearnedRange => IndexKind::LearnedRange,
71        };
72        for col_name in &idx.columns {
73            if let Some(col) = table.column(col_name) {
74                indexes.push(IndexDef {
75                    name: format!("{}_{}", idx.name, col_name),
76                    column_id: col.id as u16,
77                    kind,
78                    predicate: None,
79                });
80            }
81        }
82    }
83    for uq in &table.unique_constraints {
84        for col_name in &uq.columns {
85            if let Some(col) = table.column(col_name) {
86                indexes.push(IndexDef {
87                    name: format!("uq_{}_{}", uq.name, col_name),
88                    column_id: col.id as u16,
89                    kind: IndexKind::Bitmap,
90                    predicate: None,
91                });
92            }
93        }
94    }
95
96    CoreSchema {
97        schema_id: table.id as u64,
98        columns,
99        indexes,
100        colocation: Vec::new(),
101        constraints: TableConstraints {
102            uniques: Vec::new(),
103            foreign_keys: Vec::new(),
104            checks: core_checks,
105        },
106        clustered: false,
107    }
108}
109
110fn resolve_type(col: &Column) -> TypeId {
111    if let Some(variants) = &col.enum_values {
112        return TypeId::Enum {
113            variants: variants.iter().cloned().collect::<Vec<_>>().into(),
114        };
115    }
116    match col.storage_type {
117        ColumnType::Embedding => TypeId::Embedding {
118            dim: col.embedding_dim.unwrap_or(0),
119        },
120        other => to_core_type(other),
121    }
122}
123
124fn kit_default_to_core(default: &Option<DefaultKind>, ty: ColumnType) -> Option<DefaultExpr> {
125    let k = default.as_ref()?;
126    match k {
127        DefaultKind::Static(v) => json_to_core(v, ty).ok().map(DefaultExpr::Static),
128        DefaultKind::Now => Some(DefaultExpr::Now),
129        DefaultKind::Uuid => Some(DefaultExpr::Uuid),
130        // Sequence / CustomName are kit-only resolution paths; leave None so
131        // the kit continues to apply them at write stage time.
132        DefaultKind::Sequence(_) | DefaultKind::CustomName(_) => None,
133    }
134}
135
136pub(crate) fn to_core_flags(table: &KitTable, column: &Column) -> ColumnFlags {
137    let mut flags = ColumnFlags::empty();
138    if column.nullable {
139        flags = flags.with(ColumnFlags::NULLABLE);
140    }
141    if table.primary_key.contains(&column.name) || column.primary_key {
142        flags = flags.with(ColumnFlags::PRIMARY_KEY);
143    }
144    if column.encrypted {
145        flags = flags.with(ColumnFlags::ENCRYPTED);
146    }
147    if column.encrypted_indexable {
148        flags = flags.with(ColumnFlags::ENCRYPTED_INDEXABLE);
149    }
150    flags
151}
152
153pub(crate) fn to_core_type(ty: ColumnType) -> TypeId {
154    match ty {
155        ColumnType::Bool => TypeId::Bool,
156        ColumnType::Int8 | ColumnType::Int16 | ColumnType::Int32 | ColumnType::Int64 => {
157            TypeId::Int64
158        }
159        ColumnType::Float32 | ColumnType::Float64 => TypeId::Float64,
160        ColumnType::Text
161        | ColumnType::Bytes
162        | ColumnType::Json
163        | ColumnType::Date
164        | ColumnType::DateTime => TypeId::Bytes,
165        ColumnType::TimestampNanos => TypeId::Int64,
166        ColumnType::Date64 => TypeId::Date64,
167        ColumnType::Time64 => TypeId::Time64,
168        ColumnType::Interval => TypeId::Interval,
169        ColumnType::Decimal128 => TypeId::Decimal128 {
170            precision: 38,
171            scale: 2,
172        },
173        ColumnType::Uuid => TypeId::Uuid,
174        ColumnType::JsonNative => TypeId::Json,
175        ColumnType::Array => TypeId::Array { element_type: 0 },
176        // Dimension is filled from the column's `embedding_dim` in
177        // `to_core_schema`; a bare type has no dimension context.
178        ColumnType::Embedding => TypeId::Embedding { dim: 0 },
179        // Sparse vectors are stored as bincode'd `Vec<(u32, f32)>` in a Bytes
180        // column; the Sparse index reads the tokens from those bytes.
181        ColumnType::Sparse => TypeId::Bytes,
182    }
183}
184
185/// Convert a JSON value to a core cell value using the column type for guidance.
186pub fn json_to_core(value: &Value, ty: ColumnType) -> Result<CoreValue> {
187    Ok(match value {
188        Value::Null => CoreValue::Null,
189        Value::Bool(b) => CoreValue::Bool(*b),
190        Value::Number(n) => {
191            if let Some(i) = n.as_i64() {
192                CoreValue::Int64(i)
193            } else {
194                CoreValue::Float64(n.as_f64().unwrap_or(f64::NAN))
195            }
196        }
197        Value::String(s) => CoreValue::Bytes(s.as_bytes().to_vec()),
198        Value::Array(arr) => {
199            if ty == ColumnType::Sparse {
200                let mut terms: Vec<(u32, f32)> = Vec::with_capacity(arr.len());
201                for pair in arr {
202                    let p = pair
203                        .as_array()
204                        .ok_or_else(|| KitError::Validation("sparse expects pairs".into()))?;
205                    let token =
206                        p.first().and_then(|v| v.as_u64()).ok_or_else(|| {
207                            KitError::Validation("sparse token must be u32".into())
208                        })? as u32;
209                    let weight = p.get(1).and_then(|v| v.as_f64()).ok_or_else(|| {
210                        KitError::Validation("sparse weight must be number".into())
211                    })? as f32;
212                    terms.push((token, weight));
213                }
214                CoreValue::Bytes(
215                    bincode::serialize(&terms).map_err(|e| KitError::Validation(e.to_string()))?,
216                )
217            } else if ty == ColumnType::Embedding {
218                let mut vec = Vec::with_capacity(arr.len());
219                for v in arr {
220                    match v.as_f64() {
221                        Some(f) => vec.push(f as f32),
222                        None => {
223                            return Err(KitError::Validation("embedding expects numbers".into()))
224                        }
225                    }
226                }
227                CoreValue::Embedding(vec)
228            } else if ty == ColumnType::Bytes {
229                let mut bytes = Vec::with_capacity(arr.len());
230                for v in arr {
231                    match v {
232                        Value::Number(n) => bytes.push(n.as_i64().unwrap_or(0) as u8),
233                        _ => return Err(KitError::Validation("bytes array expected".into())),
234                    }
235                }
236                CoreValue::Bytes(bytes)
237            } else {
238                CoreValue::Bytes(serde_json::to_vec(value)?)
239            }
240        }
241        Value::Object(_) => CoreValue::Bytes(serde_json::to_vec(value)?),
242    })
243}
244
245/// Convert a core cell value back to JSON, guided by the column type.
246pub fn core_to_json(value: &CoreValue, ty: ColumnType) -> Result<Value> {
247    Ok(match (value, ty) {
248        (CoreValue::Null, _) => Value::Null,
249        (CoreValue::Bool(b), _) => Value::Bool(*b),
250        (CoreValue::Int64(i), ColumnType::Int8) => Value::Number((*i as i8).into()),
251        (CoreValue::Int64(i), ColumnType::Int16) => Value::Number((*i as i16).into()),
252        (CoreValue::Int64(i), ColumnType::Int32) => Value::Number((*i as i32).into()),
253        (CoreValue::Int64(i), ColumnType::Int64) => Value::Number((*i).into()),
254        (CoreValue::Int64(i), ColumnType::TimestampNanos) => Value::Number((*i).into()),
255        (CoreValue::Int64(i), _) => Value::Number((*i).into()),
256        (CoreValue::Float64(f), ColumnType::Float32) => serde_json::to_value(*f as f32)?,
257        (CoreValue::Float64(f), _) => serde_json::to_value(*f)?,
258        (CoreValue::Bytes(b), ColumnType::Sparse) => {
259            let terms: Vec<(u32, f32)> =
260                bincode::deserialize(b).map_err(|e| KitError::Validation(e.to_string()))?;
261            Value::Array(
262                terms
263                    .into_iter()
264                    .map(|(t, w)| Value::Array(vec![Value::from(t), Value::from(w as f64)]))
265                    .collect(),
266            )
267        }
268        (CoreValue::Bytes(b), ColumnType::Bytes) => {
269            Value::Array(b.iter().map(|x| Value::Number((*x).into())).collect())
270        }
271        (CoreValue::Bytes(b), _) => match std::str::from_utf8(b) {
272            Ok(s) => Value::String(s.to_string()),
273            Err(_) => Value::Array(b.iter().map(|x| Value::Number((*x).into())).collect()),
274        },
275        (CoreValue::Embedding(v), _) => serde_json::to_value(v)?,
276        (CoreValue::Decimal(d), _) => Value::String(d.to_string()),
277        (
278            CoreValue::Interval {
279                months,
280                days,
281                nanos,
282            },
283            _,
284        ) => {
285            serde_json::json!({ "months": months, "days": days, "nanos": nanos })
286        }
287        (CoreValue::Uuid(b), _) => {
288            let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
289            serde_json::Value::String(hex)
290        }
291        (CoreValue::Json(b), _) => serde_json::from_slice(b.as_slice())
292            .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(b).into_owned())),
293    })
294}
295
296/// Build a JSON row from a core row and a kit table definition.
297pub fn core_row_to_json(row: &mongreldb_core::memtable::Row, table: &KitTable) -> Result<Row> {
298    let mut values = Map::new();
299    for col in &table.columns {
300        let v = row
301            .columns
302            .get(&(col.id as u16))
303            .cloned()
304            .unwrap_or(CoreValue::Null);
305        values.insert(col.name.clone(), core_to_json(&v, col.storage_type)?);
306    }
307    Ok(Row {
308        row_id: row.row_id.0,
309        values,
310    })
311}
312
313/// A kit row, identified by its internal storage row id and column values.
314#[derive(Debug, Clone, PartialEq)]
315pub struct Row {
316    pub row_id: u64,
317    pub values: Map<String, Value>,
318}
319
320impl Row {
321    /// Extract the primary-key value(s) as a JSON value.
322    ///
323    /// Single-column primary keys return the scalar value; composite keys return
324    /// an object.
325    pub fn pk(&self, table: &KitTable) -> Option<Value> {
326        if table.primary_key.len() == 1 {
327            self.values.get(&table.primary_key[0]).cloned()
328        } else {
329            let mut obj = Map::new();
330            for name in &table.primary_key {
331                obj.insert(
332                    name.clone(),
333                    self.values.get(name).cloned().unwrap_or(Value::Null),
334                );
335            }
336            Some(Value::Object(obj))
337        }
338    }
339}
340
341/// Extract the primary-key value(s) from a JSON value map.
342pub fn pk_value(values: &Map<String, Value>, table: &KitTable) -> Option<Value> {
343    if table.primary_key.len() == 1 {
344        values.get(&table.primary_key[0]).cloned()
345    } else {
346        let mut obj = Map::new();
347        for name in &table.primary_key {
348            obj.insert(
349                name.clone(),
350                values.get(name).cloned().unwrap_or(Value::Null),
351            );
352        }
353        Some(Value::Object(obj))
354    }
355}
356
357/// Convert a primary-key value into the column values for lookup.
358pub fn pk_to_map(pk: &Value, table: &KitTable) -> Result<Map<String, Value>> {
359    let mut map = Map::new();
360    match pk {
361        Value::Object(obj) => {
362            for name in &table.primary_key {
363                let v = obj
364                    .get(name)
365                    .cloned()
366                    .ok_or_else(|| KitError::Validation(format!("missing pk column {name}")))?;
367                map.insert(name.clone(), v);
368            }
369        }
370        scalar if table.primary_key.len() == 1 => {
371            map.insert(table.primary_key[0].clone(), scalar.clone());
372        }
373        _ => {
374            return Err(KitError::Validation(
375                "primary key value shape mismatch".into(),
376            ))
377        }
378    }
379    Ok(map)
380}
381
382/// Build a core cell vector from a JSON row and kit table definition.
383pub fn row_to_core_cells(
384    values: &Map<String, Value>,
385    table: &KitTable,
386) -> Result<Vec<(u16, CoreValue)>> {
387    let mut cells = Vec::with_capacity(table.columns.len());
388    for col in &table.columns {
389        let v = values.get(&col.name).cloned().unwrap_or(Value::Null);
390        cells.push((col.id as u16, json_to_core(&v, col.storage_type)?));
391    }
392    Ok(cells)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use mongreldb_core::constraint::CheckExpr;
399    use mongreldb_kit_core::schema::{Column, DefaultKind, Table as KitTable};
400    use serde_json::json;
401
402    fn kit_text_column(
403        id: u32,
404        name: &str,
405        enum_values: Option<Vec<String>>,
406        regex: Option<String>,
407        default: Option<DefaultKind>,
408    ) -> Column {
409        let mut c = Column::new(id, name, ColumnType::Text);
410        c.enum_values = enum_values;
411        c.regex = regex;
412        c.default = default;
413        c
414    }
415
416    fn envelope_table(columns: Vec<Column>) -> KitTable {
417        KitTable {
418            id: 1,
419            name: "envelope".into(),
420            columns,
421            primary_key: vec!["id".into()],
422            indexes: vec![],
423            foreign_keys: vec![],
424            unique_constraints: vec![],
425            check_constraints: vec![],
426        }
427    }
428
429    #[test]
430    fn enum_values_lower_to_engine_enum_type() {
431        let table = envelope_table(vec![
432            kit_text_column(1, "id", None, None, None),
433            kit_text_column(
434                2,
435                "role",
436                Some(vec!["user".into(), "admin".into()]),
437                None,
438                None,
439            ),
440        ]);
441        let core = to_core_schema(&table);
442        let role = core.columns.iter().find(|c| c.name == "role").unwrap();
443        match &role.ty {
444            TypeId::Enum { variants } => {
445                assert_eq!(variants.as_ref(), &["user".to_string(), "admin".to_string()])
446            }
447            other => panic!("expected TypeId::Enum, got {other:?}"),
448        }
449        assert_eq!(role.default_value, None);
450        assert!(core.constraints.checks.is_empty());
451    }
452
453    #[test]
454    fn regex_lower_to_engine_check_constraint() {
455        let table = envelope_table(vec![
456            kit_text_column(1, "id", None, None, None),
457            kit_text_column(2, "slug", None, Some("^[a-z0-9-]+$".into()), None),
458        ]);
459        let core = to_core_schema(&table);
460        assert_eq!(core.constraints.checks.len(), 1, "{:?}", core.constraints);
461        let check = &core.constraints.checks[0];
462        assert_eq!(check.name, "slug_regex");
463        match &check.expr {
464            CheckExpr::Regex {
465                col,
466                pattern,
467                negated,
468                case_insensitive,
469                ..
470            } => {
471                assert_eq!(*col, 2);
472                assert_eq!(pattern, "^[a-z0-9-]+$");
473                assert!(!*negated);
474                assert!(!*case_insensitive);
475            }
476            other => panic!("expected CheckExpr::Regex, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn static_now_uuid_defaults_lower_to_engine_default_expr() {
482        let mut static_col = kit_text_column(3, "label", None, None, None);
483        static_col.default = Some(DefaultKind::Static(json!("draft")));
484        let mut now_col = kit_text_column(4, "created", None, None, None);
485        now_col.default = Some(DefaultKind::Now);
486        let mut uuid_col = kit_text_column(5, "uuid", None, None, None);
487        uuid_col.default = Some(DefaultKind::Uuid);
488        let mut seq_col = kit_text_column(6, "seq", None, None, None);
489        seq_col.default = Some(DefaultKind::Sequence("seq_users".into()));
490        let mut custom_col = kit_text_column(7, "custom", None, None, None);
491        custom_col.default = Some(DefaultKind::CustomName("named_fn".into()));
492
493        let table = envelope_table(vec![
494            kit_text_column(1, "id", None, None, None),
495            static_col,
496            now_col,
497            uuid_col,
498            seq_col,
499            custom_col,
500        ]);
501        let core = to_core_schema(&table);
502        let by = |n: &str| core.columns.iter().find(|c| c.name == n).unwrap();
503
504        assert!(matches!(
505            by("label").default_value,
506            Some(DefaultExpr::Static(CoreValue::Bytes(_)))
507        ));
508        assert!(matches!(by("created").default_value, Some(DefaultExpr::Now)));
509        assert!(matches!(by("uuid").default_value, Some(DefaultExpr::Uuid)));
510        // Kit-only shapes stay kit-side (None = no engine default).
511        assert_eq!(by("seq").default_value, None);
512        assert_eq!(by("custom").default_value, None);
513    }
514
515    #[test]
516    fn table_level_check_constraints_stay_kit_side() {
517        let mut table = envelope_table(vec![kit_text_column(1, "id", None, None, None)]);
518        table.check_constraints = vec![mongreldb_kit_core::schema::CheckConstraint {
519            name: "balance_positive".into(),
520            expr: "balance > 0".into(),
521        }];
522        let core = to_core_schema(&table);
523        // No engine CHECK emitted — kit's validate_row keeps evaluating it.
524        assert!(core.constraints.checks.is_empty());
525    }
526}