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