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