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::{
5    CheckConstraint as CoreCheckConstraint, CheckExpr, TableConstraints,
6};
7use mongreldb_core::memtable::Value as CoreValue;
8use mongreldb_core::schema::{
9    ColumnDef, ColumnFlags, DefaultExpr, IndexDef, IndexKind, IndexOptions, Schema as CoreSchema,
10    TypeId,
11};
12use mongreldb_kit_core::schema::{
13    Column, ColumnType, DefaultKind, EmbeddingSource as KitEmbeddingSource,
14    IndexKind as KitIndexKind, Table as KitTable,
15};
16use serde_json::{Map, Value};
17use std::path::PathBuf;
18
19/// Convert a kit table to a core schema.
20///
21/// Engine 0.46.x accepts column-level `enum_values`, CHECK constraints, and
22/// `Static` / `Now` / `Uuid` defaults natively on `create_table`. This pass
23/// lowers the kit model into those engine-native shapes so the kit doesn't
24/// have to re-validate them on every write.
25///
26/// Kept kit-side:
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) -> Result<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            // `None` = application-supplied (engine default). Explicit kit
42            // sources lower into the core catalog for LocalModel/GeneratedColumn.
43            embedding_source: c
44                .embedding_source
45                .as_ref()
46                .map(to_core_embedding_source),
47        })
48        .collect();
49
50    for c in &table.columns {
51        if let Some(variants) = &c.enum_values {
52            if let Some(expr) = variants
53                .iter()
54                .map(|variant| {
55                    CheckExpr::Eq(
56                        Box::new(CheckExpr::Col(c.id as u16)),
57                        Box::new(CheckExpr::Lit(CoreValue::Bytes(
58                            variant.as_bytes().to_vec(),
59                        ))),
60                    )
61                })
62                .reduce(|left, right| CheckExpr::Or(Box::new(left), Box::new(right)))
63            {
64                let id = next_check_id;
65                next_check_id = next_check_id.saturating_add(1);
66                core_checks.push(CoreCheckConstraint {
67                    id,
68                    name: format!("{}_enum", c.name),
69                    expr,
70                });
71            }
72        }
73        if let Some(pattern) = &c.regex {
74            let id = next_check_id;
75            next_check_id = next_check_id.saturating_add(1);
76            core_checks.push(CoreCheckConstraint {
77                id,
78                name: format!("{}_regex", c.name),
79                expr: CheckExpr::Regex {
80                    col: c.id as u16,
81                    pattern: pattern.clone(),
82                    negated: false,
83                    case_insensitive: false,
84                    cached: std::sync::OnceLock::new(),
85                },
86            });
87        }
88    }
89
90    for check in &table.check_constraints {
91        let id = next_check_id;
92        next_check_id = next_check_id.saturating_add(1);
93        core_checks.push(CoreCheckConstraint {
94            id,
95            name: check.name.clone(),
96            expr: lower_kit_check(&check.expr, table)?,
97        });
98    }
99    for column in &table.columns {
100        if let Some(expression) = &column.check_expr {
101            let id = next_check_id;
102            next_check_id = next_check_id.saturating_add(1);
103            core_checks.push(CoreCheckConstraint {
104                id,
105                name: format!("{}_check", column.name),
106                expr: lower_kit_check(expression, table)?,
107            });
108        }
109    }
110
111    let mut indexes: Vec<IndexDef> = Vec::new();
112    for idx in &table.indexes {
113        let kind = match idx.kind {
114            KitIndexKind::Bitmap => IndexKind::Bitmap,
115            KitIndexKind::Fm => IndexKind::FmIndex,
116            KitIndexKind::Ann => IndexKind::Ann,
117            KitIndexKind::Sparse => IndexKind::Sparse,
118            KitIndexKind::MinHash => IndexKind::MinHash,
119            KitIndexKind::LearnedRange => IndexKind::LearnedRange,
120        };
121        for col_name in &idx.columns {
122            if let Some(col) = table.column(col_name) {
123                indexes.push(IndexDef {
124                    name: format!("{}_{}", idx.name, col_name),
125                    column_id: col.id as u16,
126                    kind,
127                    predicate: None,
128                    options: IndexOptions::default(),
129                });
130            }
131        }
132    }
133    for uq in &table.unique_constraints {
134        for col_name in &uq.columns {
135            if let Some(col) = table.column(col_name) {
136                indexes.push(IndexDef {
137                    name: format!("uq_{}_{}", uq.name, col_name),
138                    column_id: col.id as u16,
139                    kind: IndexKind::Bitmap,
140                    predicate: None,
141                    options: IndexOptions::default(),
142                });
143            }
144        }
145    }
146
147    Ok(CoreSchema {
148        schema_id: table.id as u64,
149        columns,
150        indexes,
151        colocation: Vec::new(),
152        constraints: TableConstraints {
153            uniques: Vec::new(),
154            foreign_keys: Vec::new(),
155            checks: core_checks,
156        },
157        clustered: false,
158    })
159}
160
161fn lower_kit_check(expression: &str, table: &KitTable) -> Result<CheckExpr> {
162    use mongreldb_kit_core::{CheckExpression, CheckOperand, CheckOperator};
163
164    fn operand(operand: CheckOperand, table: &KitTable) -> Result<CheckExpr> {
165        Ok(match operand {
166            CheckOperand::Column(name) => CheckExpr::Col(
167                table
168                    .column(&name)
169                    .ok_or_else(|| {
170                        KitError::Validation(format!(
171                            "check expression references unknown column {name:?}"
172                        ))
173                    })?
174                    .id as u16,
175            ),
176            CheckOperand::Number(value)
177                if value.fract() == 0.0 && value >= i64::MIN as f64 && value <= i64::MAX as f64 =>
178            {
179                CheckExpr::Lit(CoreValue::Int64(value as i64))
180            }
181            CheckOperand::Number(value) => CheckExpr::Lit(CoreValue::Float64(value)),
182            CheckOperand::String(value) => CheckExpr::Lit(CoreValue::Bytes(value.into_bytes())),
183            CheckOperand::Bool(value) => CheckExpr::Lit(CoreValue::Bool(value)),
184            CheckOperand::Null => CheckExpr::Lit(CoreValue::Null),
185        })
186    }
187
188    fn lower(expression: CheckExpression, table: &KitTable) -> Result<CheckExpr> {
189        Ok(match expression {
190            CheckExpression::Compare { left, op, right } => {
191                let left = Box::new(operand(left, table)?);
192                let right = Box::new(operand(right, table)?);
193                match op {
194                    CheckOperator::Eq => CheckExpr::Eq(left, right),
195                    CheckOperator::Ne => CheckExpr::Ne(left, right),
196                    CheckOperator::Lt => CheckExpr::Lt(left, right),
197                    CheckOperator::Le => CheckExpr::Le(left, right),
198                    CheckOperator::Gt => CheckExpr::Gt(left, right),
199                    CheckOperator::Ge => CheckExpr::Ge(left, right),
200                }
201            }
202            CheckExpression::And(left, right) => CheckExpr::And(
203                Box::new(lower(*left, table)?),
204                Box::new(lower(*right, table)?),
205            ),
206            CheckExpression::Or(left, right) => CheckExpr::Or(
207                Box::new(lower(*left, table)?),
208                Box::new(lower(*right, table)?),
209            ),
210            CheckExpression::Not(expression) => {
211                CheckExpr::Not(Box::new(lower(*expression, table)?))
212            }
213        })
214    }
215
216    let parsed = mongreldb_kit_core::parse_check(expression)
217        .map_err(|error| KitError::Validation(error.0))?;
218    let lowered = lower(parsed, table)?;
219    lowered.validate().map_err(KitError::from)?;
220    Ok(lowered)
221}
222
223fn resolve_type(col: &Column) -> TypeId {
224    if let Some(variants) = &col.enum_values {
225        return TypeId::Enum {
226            variants: variants.to_vec().into(),
227        };
228    }
229    match col.storage_type {
230        ColumnType::Embedding => TypeId::Embedding {
231            dim: col.embedding_dim.unwrap_or(0),
232        },
233        other => to_core_type(other),
234    }
235}
236
237/// Lower kit embedding-source metadata to the engine catalog shape.
238pub fn to_core_embedding_source(source: &KitEmbeddingSource) -> mongreldb_core::EmbeddingSource {
239    match source {
240        KitEmbeddingSource::SuppliedByApplication => {
241            mongreldb_core::EmbeddingSource::SuppliedByApplication
242        }
243        KitEmbeddingSource::LocalModel {
244            model_path,
245            model_id,
246        } => mongreldb_core::EmbeddingSource::LocalModel {
247            model_path: PathBuf::from(model_path),
248            model_id: model_id.clone(),
249        },
250        KitEmbeddingSource::GeneratedColumn { provider } => {
251            mongreldb_core::EmbeddingSource::GeneratedColumn {
252                provider: provider.clone(),
253            }
254        }
255    }
256}
257
258fn kit_default_to_core(default: &Option<DefaultKind>, ty: ColumnType) -> Option<DefaultExpr> {
259    let k = default.as_ref()?;
260    match k {
261        DefaultKind::Static(v) => json_to_core(v, ty).ok().map(DefaultExpr::Static),
262        DefaultKind::Now => Some(DefaultExpr::Now),
263        DefaultKind::Uuid => Some(DefaultExpr::Uuid),
264        // Sequence / CustomName are kit-only resolution paths; leave None so
265        // the kit continues to apply them at write stage time.
266        DefaultKind::Sequence(_) | DefaultKind::CustomName(_) => None,
267    }
268}
269
270pub(crate) fn to_core_flags(table: &KitTable, column: &Column) -> ColumnFlags {
271    let mut flags = ColumnFlags::empty();
272    if column.nullable {
273        flags = flags.with(ColumnFlags::NULLABLE);
274    }
275    if table.primary_key.contains(&column.name) || column.primary_key {
276        flags = flags.with(ColumnFlags::PRIMARY_KEY);
277    }
278    if column.encrypted {
279        flags = flags.with(ColumnFlags::ENCRYPTED);
280    }
281    if column.encrypted_indexable {
282        flags = flags.with(ColumnFlags::ENCRYPTED_INDEXABLE);
283    }
284    flags
285}
286
287pub(crate) fn to_core_type(ty: ColumnType) -> TypeId {
288    match ty {
289        ColumnType::Bool => TypeId::Bool,
290        ColumnType::Int8 | ColumnType::Int16 | ColumnType::Int32 | ColumnType::Int64 => {
291            TypeId::Int64
292        }
293        ColumnType::Float32 | ColumnType::Float64 => TypeId::Float64,
294        ColumnType::Text
295        | ColumnType::Bytes
296        | ColumnType::Json
297        | ColumnType::Date
298        | ColumnType::DateTime => TypeId::Bytes,
299        ColumnType::TimestampNanos => TypeId::Int64,
300        ColumnType::Date64 => TypeId::Date64,
301        ColumnType::Time64 => TypeId::Time64,
302        ColumnType::Interval => TypeId::Interval,
303        ColumnType::Decimal128 => TypeId::Decimal128 {
304            precision: 38,
305            scale: 2,
306        },
307        ColumnType::Uuid => TypeId::Uuid,
308        ColumnType::JsonNative => TypeId::Json,
309        ColumnType::Array => TypeId::Array { element_type: 0 },
310        // Dimension is filled from the column's `embedding_dim` in
311        // `to_core_schema`; a bare type has no dimension context.
312        ColumnType::Embedding => TypeId::Embedding { dim: 0 },
313        // Sparse vectors are stored as bincode'd `Vec<(u32, f32)>` in a Bytes
314        // column; the Sparse index reads the tokens from those bytes.
315        ColumnType::Sparse => TypeId::Bytes,
316    }
317}
318
319/// Convert a JSON value to a core cell value using the column type for guidance.
320pub fn json_to_core(value: &Value, ty: ColumnType) -> Result<CoreValue> {
321    Ok(match value {
322        Value::Null => CoreValue::Null,
323        Value::Bool(b) => CoreValue::Bool(*b),
324        Value::Number(n) => {
325            if let Some(i) = n.as_i64() {
326                CoreValue::Int64(i)
327            } else {
328                CoreValue::Float64(n.as_f64().unwrap_or(f64::NAN))
329            }
330        }
331        Value::String(s) => CoreValue::Bytes(s.as_bytes().to_vec()),
332        Value::Array(arr) => {
333            if ty == ColumnType::Sparse {
334                let mut terms: Vec<(u32, f32)> = Vec::with_capacity(arr.len());
335                for pair in arr {
336                    let p = pair
337                        .as_array()
338                        .ok_or_else(|| KitError::Validation("sparse expects pairs".into()))?;
339                    let token =
340                        p.first().and_then(|v| v.as_u64()).ok_or_else(|| {
341                            KitError::Validation("sparse token must be u32".into())
342                        })? as u32;
343                    let weight = p.get(1).and_then(|v| v.as_f64()).ok_or_else(|| {
344                        KitError::Validation("sparse weight must be number".into())
345                    })? as f32;
346                    terms.push((token, weight));
347                }
348                CoreValue::Bytes(
349                    bincode::serialize(&terms).map_err(|e| KitError::Validation(e.to_string()))?,
350                )
351            } else if ty == ColumnType::Embedding {
352                let mut vec = Vec::with_capacity(arr.len());
353                for v in arr {
354                    match v.as_f64() {
355                        Some(f) => vec.push(f as f32),
356                        None => {
357                            return Err(KitError::Validation("embedding expects numbers".into()))
358                        }
359                    }
360                }
361                CoreValue::Embedding(vec)
362            } else if ty == ColumnType::Bytes {
363                let mut bytes = Vec::with_capacity(arr.len());
364                for v in arr {
365                    match v {
366                        Value::Number(n) => bytes.push(n.as_i64().unwrap_or(0) as u8),
367                        _ => return Err(KitError::Validation("bytes array expected".into())),
368                    }
369                }
370                CoreValue::Bytes(bytes)
371            } else {
372                CoreValue::Bytes(serde_json::to_vec(value)?)
373            }
374        }
375        Value::Object(_) => CoreValue::Bytes(serde_json::to_vec(value)?),
376    })
377}
378
379/// Convert a core cell value back to JSON, guided by the column type.
380pub fn core_to_json(value: &CoreValue, ty: ColumnType) -> Result<Value> {
381    Ok(match (value, ty) {
382        (CoreValue::Null, _) => Value::Null,
383        (CoreValue::Bool(b), _) => Value::Bool(*b),
384        (CoreValue::Int64(i), ColumnType::Int8) => Value::Number((*i as i8).into()),
385        (CoreValue::Int64(i), ColumnType::Int16) => Value::Number((*i as i16).into()),
386        (CoreValue::Int64(i), ColumnType::Int32) => Value::Number((*i as i32).into()),
387        (CoreValue::Int64(i), ColumnType::Int64) => Value::Number((*i).into()),
388        (CoreValue::Int64(i), ColumnType::TimestampNanos) => Value::Number((*i).into()),
389        (CoreValue::Int64(i), _) => Value::Number((*i).into()),
390        (CoreValue::Float64(f), ColumnType::Float32) => serde_json::to_value(*f as f32)?,
391        (CoreValue::Float64(f), _) => serde_json::to_value(*f)?,
392        (CoreValue::Bytes(b), ColumnType::Sparse) => {
393            let terms: Vec<(u32, f32)> =
394                bincode::deserialize(b).map_err(|e| KitError::Validation(e.to_string()))?;
395            Value::Array(
396                terms
397                    .into_iter()
398                    .map(|(t, w)| Value::Array(vec![Value::from(t), Value::from(w as f64)]))
399                    .collect(),
400            )
401        }
402        (CoreValue::Bytes(b), ColumnType::Bytes) => {
403            Value::Array(b.iter().map(|x| Value::Number((*x).into())).collect())
404        }
405        (CoreValue::Bytes(b), _) => match std::str::from_utf8(b) {
406            Ok(s) => Value::String(s.to_string()),
407            Err(_) => Value::Array(b.iter().map(|x| Value::Number((*x).into())).collect()),
408        },
409        (CoreValue::Embedding(v), _) => serde_json::to_value(v)?,
410        (CoreValue::Decimal(d), _) => Value::String(d.to_string()),
411        (
412            CoreValue::Interval {
413                months,
414                days,
415                nanos,
416            },
417            _,
418        ) => {
419            serde_json::json!({ "months": months, "days": days, "nanos": nanos })
420        }
421        (CoreValue::Uuid(b), _) => {
422            let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
423            serde_json::Value::String(hex)
424        }
425        (CoreValue::Json(b), _) => serde_json::from_slice(b.as_slice())
426            .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(b).into_owned())),
427    })
428}
429
430/// Build a JSON row from a core row and a kit table definition.
431pub fn core_row_to_json(row: &mongreldb_core::memtable::Row, table: &KitTable) -> Result<Row> {
432    let mut values = Map::new();
433    for col in &table.columns {
434        let v = row
435            .columns
436            .get(&(col.id as u16))
437            .cloned()
438            .unwrap_or(CoreValue::Null);
439        values.insert(col.name.clone(), core_to_json(&v, col.storage_type)?);
440    }
441    Ok(Row {
442        row_id: row.row_id.0,
443        values,
444    })
445}
446
447/// A kit row, identified by its internal storage row id and column values.
448#[derive(Debug, Clone, PartialEq)]
449pub struct Row {
450    pub row_id: u64,
451    pub values: Map<String, Value>,
452}
453
454impl Row {
455    /// Extract the primary-key value(s) as a JSON value.
456    ///
457    /// Single-column primary keys return the scalar value; composite keys return
458    /// an object.
459    pub fn pk(&self, table: &KitTable) -> Option<Value> {
460        if table.primary_key.len() == 1 {
461            self.values.get(&table.primary_key[0]).cloned()
462        } else {
463            let mut obj = Map::new();
464            for name in &table.primary_key {
465                obj.insert(
466                    name.clone(),
467                    self.values.get(name).cloned().unwrap_or(Value::Null),
468                );
469            }
470            Some(Value::Object(obj))
471        }
472    }
473}
474
475/// Extract the primary-key value(s) from a JSON value map.
476pub fn pk_value(values: &Map<String, Value>, table: &KitTable) -> Option<Value> {
477    if table.primary_key.len() == 1 {
478        values.get(&table.primary_key[0]).cloned()
479    } else {
480        let mut obj = Map::new();
481        for name in &table.primary_key {
482            obj.insert(
483                name.clone(),
484                values.get(name).cloned().unwrap_or(Value::Null),
485            );
486        }
487        Some(Value::Object(obj))
488    }
489}
490
491/// Convert a primary-key value into the column values for lookup.
492pub fn pk_to_map(pk: &Value, table: &KitTable) -> Result<Map<String, Value>> {
493    let mut map = Map::new();
494    match pk {
495        Value::Object(obj) => {
496            for name in &table.primary_key {
497                let v = obj
498                    .get(name)
499                    .cloned()
500                    .ok_or_else(|| KitError::Validation(format!("missing pk column {name}")))?;
501                map.insert(name.clone(), v);
502            }
503        }
504        scalar if table.primary_key.len() == 1 => {
505            map.insert(table.primary_key[0].clone(), scalar.clone());
506        }
507        _ => {
508            return Err(KitError::Validation(
509                "primary key value shape mismatch".into(),
510            ))
511        }
512    }
513    Ok(map)
514}
515
516/// Build a core cell vector from a JSON row and kit table definition.
517pub fn row_to_core_cells(
518    values: &Map<String, Value>,
519    table: &KitTable,
520) -> Result<Vec<(u16, CoreValue)>> {
521    let mut cells = Vec::with_capacity(table.columns.len());
522    for col in &table.columns {
523        let v = values.get(&col.name).cloned().unwrap_or(Value::Null);
524        cells.push((col.id as u16, json_to_core(&v, col.storage_type)?));
525    }
526    Ok(cells)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use mongreldb_core::constraint::CheckExpr;
533    use mongreldb_kit_core::schema::{Column, DefaultKind, Table as KitTable};
534    use serde_json::json;
535
536    fn kit_text_column(
537        id: u32,
538        name: &str,
539        enum_values: Option<Vec<String>>,
540        regex: Option<String>,
541        default: Option<DefaultKind>,
542    ) -> Column {
543        let mut c = Column::new(id, name, ColumnType::Text);
544        c.enum_values = enum_values;
545        c.regex = regex;
546        c.default = default;
547        c
548    }
549
550    fn envelope_table(columns: Vec<Column>) -> KitTable {
551        KitTable {
552            id: 1,
553            name: "envelope".into(),
554            columns,
555            primary_key: vec!["id".into()],
556            indexes: vec![],
557            foreign_keys: vec![],
558            unique_constraints: vec![],
559            check_constraints: vec![],
560        }
561    }
562
563    #[test]
564    fn enum_values_lower_to_engine_enum_type() {
565        let table = envelope_table(vec![
566            kit_text_column(1, "id", None, None, None),
567            kit_text_column(
568                2,
569                "role",
570                Some(vec!["user".into(), "admin".into()]),
571                None,
572                None,
573            ),
574        ]);
575        let core = to_core_schema(&table).unwrap();
576        let role = core.columns.iter().find(|c| c.name == "role").unwrap();
577        match &role.ty {
578            TypeId::Enum { variants } => {
579                assert_eq!(
580                    variants.as_ref(),
581                    &["user".to_string(), "admin".to_string()]
582                )
583            }
584            other => panic!("expected TypeId::Enum, got {other:?}"),
585        }
586        assert_eq!(role.default_value, None);
587        let check = &core.constraints.checks[0];
588        assert_eq!(check.name, "role_enum");
589        let valid = std::collections::HashMap::from([(2, CoreValue::Bytes(b"user".to_vec()))]);
590        let invalid = std::collections::HashMap::from([(2, CoreValue::Bytes(b"owner".to_vec()))]);
591        assert!(check.expr.satisfied(&valid));
592        assert!(!check.expr.satisfied(&invalid));
593    }
594
595    #[test]
596    fn regex_lower_to_engine_check_constraint() {
597        let table = envelope_table(vec![
598            kit_text_column(1, "id", None, None, None),
599            kit_text_column(2, "slug", None, Some("^[a-z0-9-]+$".into()), None),
600        ]);
601        let core = to_core_schema(&table).unwrap();
602        assert_eq!(core.constraints.checks.len(), 1, "{:?}", core.constraints);
603        let check = &core.constraints.checks[0];
604        assert_eq!(check.name, "slug_regex");
605        match &check.expr {
606            CheckExpr::Regex {
607                col,
608                pattern,
609                negated,
610                case_insensitive,
611                ..
612            } => {
613                assert_eq!(*col, 2);
614                assert_eq!(pattern, "^[a-z0-9-]+$");
615                assert!(!*negated);
616                assert!(!*case_insensitive);
617            }
618            other => panic!("expected CheckExpr::Regex, got {other:?}"),
619        }
620    }
621
622    #[test]
623    fn static_now_uuid_defaults_lower_to_engine_default_expr() {
624        let mut static_col = kit_text_column(3, "label", None, None, None);
625        static_col.default = Some(DefaultKind::Static(json!("draft")));
626        let mut now_col = kit_text_column(4, "created", None, None, None);
627        now_col.default = Some(DefaultKind::Now);
628        let mut uuid_col = kit_text_column(5, "uuid", None, None, None);
629        uuid_col.default = Some(DefaultKind::Uuid);
630        let mut seq_col = kit_text_column(6, "seq", None, None, None);
631        seq_col.default = Some(DefaultKind::Sequence("seq_users".into()));
632        let mut custom_col = kit_text_column(7, "custom", None, None, None);
633        custom_col.default = Some(DefaultKind::CustomName("named_fn".into()));
634
635        let table = envelope_table(vec![
636            kit_text_column(1, "id", None, None, None),
637            static_col,
638            now_col,
639            uuid_col,
640            seq_col,
641            custom_col,
642        ]);
643        let core = to_core_schema(&table).unwrap();
644        let by = |n: &str| core.columns.iter().find(|c| c.name == n).unwrap();
645
646        assert!(matches!(
647            by("label").default_value,
648            Some(DefaultExpr::Static(CoreValue::Bytes(_)))
649        ));
650        assert!(matches!(
651            by("created").default_value,
652            Some(DefaultExpr::Now)
653        ));
654        assert!(matches!(by("uuid").default_value, Some(DefaultExpr::Uuid)));
655        // Kit-only shapes stay kit-side (None = no engine default).
656        assert_eq!(by("seq").default_value, None);
657        assert_eq!(by("custom").default_value, None);
658    }
659
660    #[test]
661    fn embedding_source_kinds_lower_to_core_catalog() {
662        use mongreldb_kit_core::schema::EmbeddingSource as KitSrc;
663
664        let mut app = Column::new(2, "app_vec", ColumnType::Embedding);
665        app.embedding_dim = Some(4);
666        app.embedding_source = Some(KitSrc::SuppliedByApplication);
667
668        let mut local = Column::new(3, "local_vec", ColumnType::Embedding);
669        local.embedding_dim = Some(4);
670        local.embedding_source = Some(KitSrc::LocalModel {
671            model_path: "/models/demo".into(),
672            model_id: "demo".into(),
673        });
674
675        let mut gen = Column::new(4, "gen_vec", ColumnType::Embedding);
676        gen.embedding_dim = Some(8);
677        gen.embedding_source = Some(KitSrc::GeneratedColumn {
678            provider: "my-provider".into(),
679        });
680
681        let mut omitted = Column::new(5, "omit_vec", ColumnType::Embedding);
682        omitted.embedding_dim = Some(4);
683        // embedding_source left None → application-supplied default
684
685        let table = envelope_table(vec![
686            kit_text_column(1, "id", None, None, None),
687            app,
688            local,
689            gen,
690            omitted,
691        ]);
692        let core = to_core_schema(&table).unwrap();
693        let by = |n: &str| core.columns.iter().find(|c| c.name == n).unwrap();
694
695        assert_eq!(
696            by("app_vec").embedding_source,
697            Some(mongreldb_core::EmbeddingSource::SuppliedByApplication)
698        );
699        assert_eq!(
700            by("local_vec").embedding_source,
701            Some(mongreldb_core::EmbeddingSource::LocalModel {
702                model_path: PathBuf::from("/models/demo"),
703                model_id: "demo".into(),
704            })
705        );
706        assert_eq!(
707            by("gen_vec").embedding_source,
708            Some(mongreldb_core::EmbeddingSource::GeneratedColumn {
709                provider: "my-provider".into(),
710            })
711        );
712        assert_eq!(by("omit_vec").embedding_source, None);
713    }
714
715    #[test]
716    fn table_and_column_checks_lower_to_engine() {
717        let mut balance = Column::new(2, "balance", ColumnType::Int64);
718        balance.check_expr = Some("balance <= 100".into());
719        let mut table = envelope_table(vec![kit_text_column(1, "id", None, None, None), balance]);
720        table.check_constraints = vec![mongreldb_kit_core::schema::CheckConstraint {
721            name: "balance_positive".into(),
722            expr: "balance > 0 AND id > 0".into(),
723        }];
724        let core = to_core_schema(&table).unwrap();
725        assert_eq!(core.constraints.checks.len(), 2);
726        let valid =
727            std::collections::HashMap::from([(1, CoreValue::Int64(1)), (2, CoreValue::Int64(50))]);
728        let invalid =
729            std::collections::HashMap::from([(1, CoreValue::Int64(1)), (2, CoreValue::Int64(101))]);
730        assert!(core
731            .constraints
732            .checks
733            .iter()
734            .all(|check| check.expr.satisfied(&valid)));
735        assert!(core
736            .constraints
737            .checks
738            .iter()
739            .any(|check| !check.expr.satisfied(&invalid)));
740
741        table.check_constraints[0].expr = "missing > 0".into();
742        assert!(to_core_schema(&table).is_err());
743    }
744}