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