1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ColumnType {
13 Bool,
14 Int8,
15 Int16,
16 Int32,
17 Int64,
18 Float32,
19 Float64,
20 Text,
21 Bytes,
22 Json,
23 Date,
24 DateTime,
25 TimestampNanos,
26 Date64,
27 Time64,
28 Interval,
29 Decimal128,
30 Uuid,
32 JsonNative,
34 Array,
36 Embedding,
39 Sparse,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DefaultKind {
48 Static(serde_json::Value),
50 Now,
52 Uuid,
54 Sequence(String),
56 CustomName(String),
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum EmbeddingSpecNormalization {
69 #[default]
70 None,
71 L2,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum EmbeddingWriteFailurePolicy {
77 #[default]
78 AbortWrite,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct GeneratedEmbeddingSpec {
83 pub provider_id: String,
84 pub model_id: String,
85 pub model_version: String,
86 pub source_columns: Vec<u32>,
87 pub input_template: String,
88 pub dimension: u32,
89 pub normalization: EmbeddingSpecNormalization,
90 pub failure_policy: EmbeddingWriteFailurePolicy,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95pub enum EmbeddingSource {
96 SuppliedByApplication,
98 LocalModel {
100 model_path: String,
102 model_id: String,
104 },
105 ConfiguredModel {
107 provider_id: String,
108 model_id: String,
109 model_version: String,
110 },
111 GeneratedColumn {
113 provider: String,
115 },
116 GeneratedColumnSpec { spec: GeneratedEmbeddingSpec },
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct Column {
123 pub id: u32,
125 pub name: String,
127 pub storage_type: ColumnType,
129 pub application_type: ColumnType,
131 pub nullable: bool,
133 pub primary_key: bool,
135 pub default: Option<DefaultKind>,
137 pub generated: bool,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub enum_values: Option<Vec<String>>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub min: Option<f64>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub max: Option<f64>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub min_length: Option<usize>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub max_length: Option<usize>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub regex: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub check_expr: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub embedding_dim: Option<u32>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub embedding_source: Option<EmbeddingSource>,
167 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169 pub encrypted: bool,
170 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173 pub encrypted_indexable: bool,
174}
175
176impl Column {
177 pub fn new(id: u32, name: impl Into<String>, storage_type: ColumnType) -> Self {
179 Self {
180 id,
181 name: name.into(),
182 storage_type,
183 application_type: storage_type,
184 nullable: false,
185 primary_key: false,
186 default: None,
187 generated: false,
188 enum_values: None,
189 min: None,
190 max: None,
191 min_length: None,
192 max_length: None,
193 regex: None,
194 check_expr: None,
195 embedding_dim: None,
196 embedding_source: None,
197 encrypted: false,
198 encrypted_indexable: false,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum IndexKind {
207 #[default]
209 Bitmap,
210 Fm,
212 Ann,
214 Sparse,
216 MinHash,
219 LearnedRange,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum AnnQuantization {
228 #[default]
230 BinarySign,
231 Dense,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
237pub struct Index {
238 pub name: String,
239 pub columns: Vec<String>,
240 pub unique: bool,
241 #[serde(default)]
244 pub kind: IndexKind,
245 #[serde(default)]
247 pub ann_quantization: AnnQuantization,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub predicate: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub ann_m: Option<usize>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub ann_ef_construction: Option<usize>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub ann_ef_search: Option<usize>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub minhash_permutations: Option<usize>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub minhash_bands: Option<usize>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub learned_range_epsilon: Option<usize>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct UniqueConstraint {
274 pub name: String,
275 pub columns: Vec<String>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ForeignKey {
281 pub name: String,
282 pub columns: Vec<String>,
283 pub references_table: String,
284 pub references_columns: Vec<String>,
285 #[serde(default)]
286 pub on_delete: ForeignKeyAction,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum ForeignKeyAction {
293 #[default]
294 Restrict,
295 Cascade,
296 SetNull,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct CheckConstraint {
302 pub name: String,
303 pub expr: String,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct Sequence {
309 pub name: String,
310 pub next_value: i64,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315pub struct Table {
316 pub id: u32,
318 pub name: String,
319 pub columns: Vec<Column>,
320 pub primary_key: Vec<String>,
321 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub indexes: Vec<Index>,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub foreign_keys: Vec<ForeignKey>,
325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
326 pub unique_constraints: Vec<UniqueConstraint>,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub check_constraints: Vec<CheckConstraint>,
329}
330
331impl Table {
332 pub fn column(&self, name: &str) -> Option<&Column> {
334 self.columns.iter().find(|c| c.name == name)
335 }
336
337 pub fn is_pk_column(&self, name: &str) -> bool {
339 self.primary_key.iter().any(|c| c == name)
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
345pub enum SchemaError {
346 #[error("duplicate table name \"{0}\"")]
347 DuplicateTableName(String),
348 #[error("duplicate table id {0}")]
349 DuplicateTableId(u32),
350 #[error("duplicate column name \"{1}\" in table \"{0}\"")]
351 DuplicateColumnName(String, String),
352 #[error("duplicate column id {1} in table \"{0}\"")]
353 DuplicateColumnId(String, u32),
354 #[error("primary key column \"{1}\" not found in table \"{0}\"")]
355 MissingPrimaryKeyColumn(String, String),
356 #[error("index \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
357 MissingIndexColumn(String, String, String),
358 #[error("unique constraint \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
359 MissingUniqueColumn(String, String, String),
360 #[error("foreign key \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
361 MissingForeignKeyColumn(String, String, String),
362 #[error("foreign key \"{1}\" references unknown table \"{2}\"")]
363 MissingReferencedTable(String, String, String),
364 #[error("foreign key \"{1}\" references unknown column \"{2}\" on table \"{3}\"")]
365 MissingReferencedColumn(String, String, String, String),
366 #[error(
367 "column \"{1}\" on table \"{0}\" sets embedding_source but is not an embedding column"
368 )]
369 EmbeddingSourceOnNonEmbedding(String, String),
370 #[error(
371 "embedding column \"{1}\" on table \"{0}\" with LocalModel/GeneratedColumn source requires embedding_dim > 0"
372 )]
373 EmbeddingSourceMissingDim(String, String),
374 #[error("generated embedding column \"{1}\" on table \"{0}\" is invalid: {2}")]
375 InvalidGeneratedEmbeddingSpec(String, String, String),
376}
377
378#[derive(Debug, Clone, PartialEq, Serialize)]
380pub struct Schema {
381 pub tables: Vec<Table>,
382 by_name: HashMap<String, usize>,
383 by_id: HashMap<u32, usize>,
384}
385
386impl<'de> serde::Deserialize<'de> for Schema {
387 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388 where
389 D: serde::Deserializer<'de>,
390 {
391 #[derive(serde::Deserialize)]
392 struct SchemaHelper {
393 tables: Vec<Table>,
394 }
395 let helper = SchemaHelper::deserialize(deserializer)?;
396 Schema::new(helper.tables).map_err(serde::de::Error::custom)
397 }
398}
399
400fn synthesize_unique_from_indexes(table: &mut Table) {
405 let mut synthesized: Vec<UniqueConstraint> = Vec::new();
406 for idx in &table.indexes {
407 if !idx.unique {
408 continue;
409 }
410 let covered = table
411 .unique_constraints
412 .iter()
413 .chain(synthesized.iter())
414 .any(|u| u.columns == idx.columns);
415 if !covered {
416 synthesized.push(UniqueConstraint {
417 name: idx.name.clone(),
418 columns: idx.columns.clone(),
419 });
420 }
421 }
422 table.unique_constraints.extend(synthesized);
423}
424
425impl Schema {
426 pub fn new(mut tables: Vec<Table>) -> Result<Self, SchemaError> {
428 for table in &mut tables {
429 synthesize_unique_from_indexes(table);
430 }
431
432 let mut by_name = HashMap::with_capacity(tables.len());
433 let mut by_id = HashMap::with_capacity(tables.len());
434
435 for (idx, table) in tables.iter().enumerate() {
436 if by_name.contains_key(&table.name) {
437 return Err(SchemaError::DuplicateTableName(table.name.clone()));
438 }
439 if by_id.contains_key(&table.id) {
440 return Err(SchemaError::DuplicateTableId(table.id));
441 }
442 by_name.insert(table.name.clone(), idx);
443 by_id.insert(table.id, idx);
444 }
445
446 for table in &tables {
447 Self::validate_table(table, &by_name)?;
448 }
449
450 Ok(Self {
451 tables,
452 by_name,
453 by_id,
454 })
455 }
456
457 fn validate_table(
458 table: &Table,
459 table_names: &HashMap<String, usize>,
460 ) -> Result<(), SchemaError> {
461 let mut column_names = HashMap::with_capacity(table.columns.len());
462 let mut column_ids = HashMap::with_capacity(table.columns.len());
463
464 for col in &table.columns {
465 if column_names.contains_key(&col.name) {
466 return Err(SchemaError::DuplicateColumnName(
467 table.name.clone(),
468 col.name.clone(),
469 ));
470 }
471 if column_ids.contains_key(&col.id) {
472 return Err(SchemaError::DuplicateColumnId(table.name.clone(), col.id));
473 }
474 if col.embedding_source.is_some() && col.storage_type != ColumnType::Embedding {
475 return Err(SchemaError::EmbeddingSourceOnNonEmbedding(
476 table.name.clone(),
477 col.name.clone(),
478 ));
479 }
480 if matches!(
481 col.embedding_source,
482 Some(
483 EmbeddingSource::LocalModel { .. }
484 | EmbeddingSource::ConfiguredModel { .. }
485 | EmbeddingSource::GeneratedColumn { .. }
486 | EmbeddingSource::GeneratedColumnSpec { .. }
487 )
488 ) && col.embedding_dim.unwrap_or(0) == 0
489 {
490 return Err(SchemaError::EmbeddingSourceMissingDim(
491 table.name.clone(),
492 col.name.clone(),
493 ));
494 }
495 column_names.insert(col.name.clone(), col.id);
496 column_ids.insert(col.id, col.name.clone());
497 }
498
499 for col in &table.columns {
500 if let Some(EmbeddingSource::ConfiguredModel {
501 provider_id,
502 model_id,
503 model_version,
504 }) = col.embedding_source.as_ref()
505 {
506 if provider_id.is_empty() || model_id.is_empty() || model_version.is_empty() {
507 return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
508 table.name.clone(),
509 col.name.clone(),
510 "provider, model, and version are required".into(),
511 ));
512 }
513 }
514 }
515
516 for col in &table.columns {
517 let Some(EmbeddingSource::GeneratedColumnSpec { spec }) = col.embedding_source.as_ref()
518 else {
519 continue;
520 };
521 if spec.provider_id.is_empty()
522 || spec.model_id.is_empty()
523 || spec.model_version.is_empty()
524 || spec.source_columns.is_empty()
525 {
526 return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
527 table.name.clone(),
528 col.name.clone(),
529 "provider, model, version, and source columns are required".into(),
530 ));
531 }
532 if col.embedding_dim != Some(spec.dimension) {
533 return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
534 table.name.clone(),
535 col.name.clone(),
536 "spec dimension must match embedding_dim".into(),
537 ));
538 }
539 let mut seen = HashSet::new();
540 if spec.source_columns.iter().any(|source_id| {
541 *source_id == col.id
542 || !seen.insert(*source_id)
543 || !column_ids.contains_key(source_id)
544 }) {
545 return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
546 table.name.clone(),
547 col.name.clone(),
548 "source columns must exist, be unique, and exclude the target".into(),
549 ));
550 }
551 }
552
553 for pk in &table.primary_key {
554 if !column_names.contains_key(pk) {
555 return Err(SchemaError::MissingPrimaryKeyColumn(
556 table.name.clone(),
557 pk.clone(),
558 ));
559 }
560 }
561
562 for idx in &table.indexes {
563 for col in &idx.columns {
564 if !column_names.contains_key(col) {
565 return Err(SchemaError::MissingIndexColumn(
566 table.name.clone(),
567 idx.name.clone(),
568 col.clone(),
569 ));
570 }
571 }
572 }
573
574 for uq in &table.unique_constraints {
575 for col in &uq.columns {
576 if !column_names.contains_key(col) {
577 return Err(SchemaError::MissingUniqueColumn(
578 table.name.clone(),
579 uq.name.clone(),
580 col.clone(),
581 ));
582 }
583 }
584 }
585
586 for fk in &table.foreign_keys {
587 for col in &fk.columns {
588 if !column_names.contains_key(col) {
589 return Err(SchemaError::MissingForeignKeyColumn(
590 table.name.clone(),
591 fk.name.clone(),
592 col.clone(),
593 ));
594 }
595 }
596 if !table_names.contains_key(&fk.references_table) {
597 return Err(SchemaError::MissingReferencedTable(
598 table.name.clone(),
599 fk.name.clone(),
600 fk.references_table.clone(),
601 ));
602 }
603 }
604
605 Ok(())
606 }
607
608 pub fn table(&self, name: &str) -> Option<&Table> {
610 self.by_name.get(name).map(|&idx| &self.tables[idx])
611 }
612
613 pub fn table_by_id(&self, id: u32) -> Option<&Table> {
615 self.by_id.get(&id).map(|&idx| &self.tables[idx])
616 }
617
618 pub fn has_table(&self, name: &str) -> bool {
620 self.by_name.contains_key(name)
621 }
622
623 pub fn rename_table(&mut self, from: &str, to: &str) -> bool {
628 if from == to {
629 return self.has_table(from);
630 }
631 if !self.has_table(from) || self.has_table(to) {
632 return false;
633 }
634 let idx = *self.by_name.get(from).unwrap();
635 self.tables[idx].name = to.to_string();
636 self.by_name.remove(from);
637 self.by_name.insert(to.to_string(), idx);
638 true
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn make_table(name: &str, id: u32) -> Table {
647 Table {
648 id,
649 name: name.into(),
650 columns: vec![Column::new(1, "id", ColumnType::Int64)],
651 primary_key: vec!["id".into()],
652 indexes: vec![],
653 foreign_keys: vec![],
654 unique_constraints: vec![],
655 check_constraints: vec![],
656 }
657 }
658
659 #[test]
660 fn schema_rejects_duplicate_table_name() {
661 let err = Schema::new(vec![make_table("a", 1), make_table("a", 2)]).unwrap_err();
662 assert!(matches!(err, SchemaError::DuplicateTableName(n) if n == "a"));
663 }
664
665 #[test]
666 fn schema_rejects_duplicate_table_id() {
667 let err = Schema::new(vec![make_table("a", 1), make_table("b", 1)]).unwrap_err();
668 assert!(matches!(err, SchemaError::DuplicateTableId(1)));
669 }
670
671 #[test]
672 fn schema_rejects_missing_pk_column() {
673 let t = Table {
674 id: 1,
675 name: "t".into(),
676 columns: vec![Column::new(1, "x", ColumnType::Text)],
677 primary_key: vec!["id".into()],
678 indexes: vec![],
679 foreign_keys: vec![],
680 unique_constraints: vec![],
681 check_constraints: vec![],
682 };
683 let err = Schema::new(vec![t]).unwrap_err();
684 assert!(matches!(err, SchemaError::MissingPrimaryKeyColumn(_, _)));
685 }
686
687 #[test]
688 fn unique_index_synthesizes_unique_constraint() {
689 let schema = Schema::new(vec![Table {
690 id: 1,
691 name: "users".into(),
692 columns: vec![
693 Column::new(1, "id", ColumnType::Int64),
694 Column::new(2, "email", ColumnType::Text),
695 Column::new(3, "handle", ColumnType::Text),
696 ],
697 primary_key: vec!["id".into()],
698 indexes: vec![
699 Index {
700 name: "idx_email".into(),
701 columns: vec!["email".into()],
702 unique: true,
703 kind: Default::default(),
704 ann_quantization: Default::default(),
705 ..Default::default()
706 },
707 Index {
709 name: "idx_handle".into(),
710 columns: vec!["handle".into()],
711 unique: false,
712 kind: Default::default(),
713 ann_quantization: Default::default(),
714 ..Default::default()
715 },
716 ],
717 foreign_keys: vec![],
718 unique_constraints: vec![],
719 check_constraints: vec![],
720 }])
721 .unwrap();
722 let table = schema.table("users").unwrap();
723 assert_eq!(table.unique_constraints.len(), 1);
724 assert_eq!(
725 table.unique_constraints[0].columns,
726 vec!["email".to_string()]
727 );
728 }
729
730 #[test]
731 fn unique_index_does_not_duplicate_existing_constraint() {
732 let schema = Schema::new(vec![Table {
733 id: 1,
734 name: "users".into(),
735 columns: vec![
736 Column::new(1, "id", ColumnType::Int64),
737 Column::new(2, "email", ColumnType::Text),
738 ],
739 primary_key: vec!["id".into()],
740 indexes: vec![Index {
741 name: "idx_email".into(),
742 columns: vec!["email".into()],
743 unique: true,
744 kind: Default::default(),
745 ann_quantization: Default::default(),
746 ..Default::default()
747 }],
748 foreign_keys: vec![],
749 unique_constraints: vec![UniqueConstraint {
750 name: "uq_email".into(),
751 columns: vec!["email".into()],
752 }],
753 check_constraints: vec![],
754 }])
755 .unwrap();
756 let table = schema.table("users").unwrap();
758 assert_eq!(table.unique_constraints.len(), 1);
759 assert_eq!(table.unique_constraints[0].name, "uq_email");
760 }
761
762 #[test]
763 fn schema_roundtrips_json() {
764 let schema = Schema::new(vec![Table {
765 id: 1,
766 name: "users".into(),
767 columns: vec![
768 Column::new(1, "id", ColumnType::Int64),
769 Column {
770 nullable: true,
771 ..Column::new(2, "email", ColumnType::Text)
772 },
773 ],
774 primary_key: vec!["id".into()],
775 indexes: vec![Index {
776 name: "idx_email".into(),
777 columns: vec!["email".into()],
778 unique: true,
779 kind: Default::default(),
780 ann_quantization: Default::default(),
781 ..Default::default()
782 }],
783 foreign_keys: vec![],
784 unique_constraints: vec![],
785 check_constraints: vec![CheckConstraint {
786 name: "chk_id_positive".into(),
787 expr: "id > 0".into(),
788 }],
789 }])
790 .unwrap();
791
792 let json = serde_json::to_string(&schema).unwrap();
793 let decoded: Schema = serde_json::from_str(&json).unwrap();
794 assert_eq!(decoded.tables.len(), 1);
795 assert_eq!(decoded.table("users").unwrap().columns.len(), 2);
796 }
797
798 #[test]
799 fn dense_ann_quantization_roundtrips_and_old_json_defaults_binary() {
800 let dense = Index {
801 name: "idx_embedding".into(),
802 columns: vec!["embedding".into()],
803 unique: false,
804 kind: IndexKind::Ann,
805 ann_quantization: AnnQuantization::Dense,
806 predicate: Some("embedding IS NOT NULL".into()),
807 ann_m: Some(24),
808 ann_ef_construction: Some(96),
809 ann_ef_search: Some(48),
810 ..Default::default()
811 };
812 let json = serde_json::to_value(&dense).unwrap();
813 assert_eq!(json["ann_quantization"], "dense");
814 assert_eq!(json["ann_m"], 24);
815 assert_eq!(json["predicate"], "embedding IS NOT NULL");
816 assert_eq!(
817 serde_json::from_value::<Index>(json)
818 .unwrap()
819 .ann_quantization,
820 AnnQuantization::Dense
821 );
822
823 let old: Index = serde_json::from_value(serde_json::json!({
824 "name": "idx_embedding",
825 "columns": ["embedding"],
826 "unique": false,
827 "kind": "ann"
828 }))
829 .unwrap();
830 assert_eq!(old.ann_quantization, AnnQuantization::BinarySign);
831 }
832
833 #[test]
834 fn embedding_source_roundtrips_json() {
835 let mut emb = Column::new(2, "vec", ColumnType::Embedding);
836 emb.embedding_dim = Some(4);
837 emb.embedding_source = Some(EmbeddingSource::LocalModel {
838 model_path: "/models/kit-mini".into(),
839 model_id: "kit-mini".into(),
840 });
841 let schema = Schema::new(vec![Table {
842 id: 1,
843 name: "docs".into(),
844 columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
845 primary_key: vec!["id".into()],
846 indexes: vec![],
847 foreign_keys: vec![],
848 unique_constraints: vec![],
849 check_constraints: vec![],
850 }])
851 .unwrap();
852 let json = serde_json::to_string(&schema).unwrap();
853 assert!(json.contains("local_model"));
854 assert!(json.contains("kit-mini"));
855 let decoded: Schema = serde_json::from_str(&json).unwrap();
856 let col = decoded.table("docs").unwrap().column("vec").unwrap();
857 assert_eq!(
858 col.embedding_source,
859 Some(EmbeddingSource::LocalModel {
860 model_path: "/models/kit-mini".into(),
861 model_id: "kit-mini".into(),
862 })
863 );
864 }
865
866 #[test]
867 fn embedding_source_rejected_on_non_embedding() {
868 let mut col = Column::new(2, "name", ColumnType::Text);
869 col.embedding_source = Some(EmbeddingSource::SuppliedByApplication);
870 let err = Schema::new(vec![Table {
871 id: 1,
872 name: "t".into(),
873 columns: vec![Column::new(1, "id", ColumnType::Int64), col],
874 primary_key: vec!["id".into()],
875 indexes: vec![],
876 foreign_keys: vec![],
877 unique_constraints: vec![],
878 check_constraints: vec![],
879 }])
880 .unwrap_err();
881 assert!(matches!(
882 err,
883 SchemaError::EmbeddingSourceOnNonEmbedding(_, _)
884 ));
885 }
886
887 #[test]
888 fn generated_embedding_requires_dim() {
889 let mut emb = Column::new(2, "vec", ColumnType::Embedding);
890 emb.embedding_source = Some(EmbeddingSource::GeneratedColumn {
891 provider: "local-test".into(),
892 });
893 let err = Schema::new(vec![Table {
894 id: 1,
895 name: "t".into(),
896 columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
897 primary_key: vec!["id".into()],
898 indexes: vec![],
899 foreign_keys: vec![],
900 unique_constraints: vec![],
901 check_constraints: vec![],
902 }])
903 .unwrap_err();
904 assert!(matches!(err, SchemaError::EmbeddingSourceMissingDim(_, _)));
905 }
906
907 #[test]
908 fn generated_embedding_spec_roundtrips_and_validates_sources() {
909 let mut emb = Column::new(3, "vec", ColumnType::Embedding);
910 emb.embedding_dim = Some(4);
911 emb.embedding_source = Some(EmbeddingSource::GeneratedColumnSpec {
912 spec: GeneratedEmbeddingSpec {
913 provider_id: "provider".into(),
914 model_id: "model".into(),
915 model_version: "1".into(),
916 source_columns: vec![2],
917 input_template: "{body}".into(),
918 dimension: 4,
919 normalization: EmbeddingSpecNormalization::None,
920 failure_policy: EmbeddingWriteFailurePolicy::AbortWrite,
921 },
922 });
923 let schema = Schema::new(vec![Table {
924 id: 1,
925 name: "docs".into(),
926 columns: vec![
927 Column::new(1, "id", ColumnType::Int64),
928 Column::new(2, "body", ColumnType::Text),
929 emb,
930 ],
931 primary_key: vec!["id".into()],
932 indexes: vec![],
933 foreign_keys: vec![],
934 unique_constraints: vec![],
935 check_constraints: vec![],
936 }])
937 .unwrap();
938 let json = serde_json::to_string(&schema).unwrap();
939 assert!(json.contains("generated_column_spec"));
940 assert_eq!(serde_json::from_str::<Schema>(&json).unwrap(), schema);
941 }
942}