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