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