use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColumnType {
Bool,
Int8,
Int16,
Int32,
Int64,
Float32,
Float64,
Text,
Bytes,
Json,
Date,
DateTime,
TimestampNanos,
Date64,
Time64,
Interval,
Decimal128,
Uuid,
JsonNative,
Array,
Embedding,
Sparse,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DefaultKind {
Static(serde_json::Value),
Now,
Uuid,
Sequence(String),
CustomName(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingSpecNormalization {
#[default]
None,
L2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingWriteFailurePolicy {
#[default]
AbortWrite,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedEmbeddingSpec {
pub provider_id: String,
pub model_id: String,
pub model_version: String,
pub source_columns: Vec<u32>,
pub input_template: String,
pub dimension: u32,
pub normalization: EmbeddingSpecNormalization,
pub failure_policy: EmbeddingWriteFailurePolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EmbeddingSource {
SuppliedByApplication,
LocalModel {
model_path: String,
model_id: String,
},
ConfiguredModel {
provider_id: String,
model_id: String,
model_version: String,
},
GeneratedColumn {
provider: String,
},
GeneratedColumnSpec { spec: GeneratedEmbeddingSpec },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Column {
pub id: u32,
pub name: String,
pub storage_type: ColumnType,
pub application_type: ColumnType,
pub nullable: bool,
pub primary_key: bool,
pub default: Option<DefaultKind>,
pub generated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_length: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_expr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_dim: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_source: Option<EmbeddingSource>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub encrypted: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub encrypted_indexable: bool,
}
impl Column {
pub fn new(id: u32, name: impl Into<String>, storage_type: ColumnType) -> Self {
Self {
id,
name: name.into(),
storage_type,
application_type: storage_type,
nullable: false,
primary_key: false,
default: None,
generated: false,
enum_values: None,
min: None,
max: None,
min_length: None,
max_length: None,
regex: None,
check_expr: None,
embedding_dim: None,
embedding_source: None,
encrypted: false,
encrypted_indexable: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexKind {
#[default]
Bitmap,
Fm,
Ann,
Sparse,
MinHash,
LearnedRange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnQuantization {
#[default]
BinarySign,
Dense,
Product { num_subvectors: u16, bits: u8 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AnnAlgorithm {
#[default]
#[serde(rename = "hnsw")]
Hnsw,
#[serde(rename = "diskann")]
DiskAnn,
#[serde(rename = "ivf")]
Ivf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Index {
pub name: String,
pub columns: Vec<String>,
pub unique: bool,
#[serde(default)]
pub kind: IndexKind,
#[serde(default)]
pub ann_quantization: AnnQuantization,
#[serde(default)]
pub ann_algorithm: AnnAlgorithm,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predicate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_m: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_ef_construction: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_ef_search: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_diskann_r: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_diskann_l: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_diskann_beam_width: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_diskann_alpha: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_ivf_nlist: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_ivf_nprobe: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_ivf_training_samples: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_pq_training_samples: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_pq_seed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ann_pq_rerank_factor: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minhash_permutations: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minhash_bands: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learned_range_epsilon: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UniqueConstraint {
pub name: String,
pub columns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignKey {
pub name: String,
pub columns: Vec<String>,
pub references_table: String,
pub references_columns: Vec<String>,
#[serde(default)]
pub on_delete: ForeignKeyAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForeignKeyAction {
#[default]
Restrict,
Cascade,
SetNull,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckConstraint {
pub name: String,
pub expr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sequence {
pub name: String,
pub next_value: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Table {
pub id: u32,
pub name: String,
pub columns: Vec<Column>,
pub primary_key: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub indexes: Vec<Index>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub foreign_keys: Vec<ForeignKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unique_constraints: Vec<UniqueConstraint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub check_constraints: Vec<CheckConstraint>,
}
impl Table {
pub fn column(&self, name: &str) -> Option<&Column> {
self.columns.iter().find(|c| c.name == name)
}
pub fn is_pk_column(&self, name: &str) -> bool {
self.primary_key.iter().any(|c| c == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaError {
#[error("duplicate table name \"{0}\"")]
DuplicateTableName(String),
#[error("duplicate table id {0}")]
DuplicateTableId(u32),
#[error("duplicate column name \"{1}\" in table \"{0}\"")]
DuplicateColumnName(String, String),
#[error("duplicate column id {1} in table \"{0}\"")]
DuplicateColumnId(String, u32),
#[error("primary key column \"{1}\" not found in table \"{0}\"")]
MissingPrimaryKeyColumn(String, String),
#[error("index \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
MissingIndexColumn(String, String, String),
#[error("unique constraint \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
MissingUniqueColumn(String, String, String),
#[error("foreign key \"{1}\" references unknown column \"{2}\" in table \"{0}\"")]
MissingForeignKeyColumn(String, String, String),
#[error("foreign key \"{1}\" references unknown table \"{2}\"")]
MissingReferencedTable(String, String, String),
#[error("foreign key \"{1}\" references unknown column \"{2}\" on table \"{3}\"")]
MissingReferencedColumn(String, String, String, String),
#[error(
"column \"{1}\" on table \"{0}\" sets embedding_source but is not an embedding column"
)]
EmbeddingSourceOnNonEmbedding(String, String),
#[error(
"embedding column \"{1}\" on table \"{0}\" with LocalModel/GeneratedColumn source requires embedding_dim > 0"
)]
EmbeddingSourceMissingDim(String, String),
#[error("generated embedding column \"{1}\" on table \"{0}\" is invalid: {2}")]
InvalidGeneratedEmbeddingSpec(String, String, String),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Schema {
pub tables: Vec<Table>,
by_name: HashMap<String, usize>,
by_id: HashMap<u32, usize>,
}
impl<'de> serde::Deserialize<'de> for Schema {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct SchemaHelper {
tables: Vec<Table>,
}
let helper = SchemaHelper::deserialize(deserializer)?;
Schema::new(helper.tables).map_err(serde::de::Error::custom)
}
}
fn synthesize_unique_from_indexes(table: &mut Table) {
let mut synthesized: Vec<UniqueConstraint> = Vec::new();
for idx in &table.indexes {
if !idx.unique {
continue;
}
let covered = table
.unique_constraints
.iter()
.chain(synthesized.iter())
.any(|u| u.columns == idx.columns);
if !covered {
synthesized.push(UniqueConstraint {
name: idx.name.clone(),
columns: idx.columns.clone(),
});
}
}
table.unique_constraints.extend(synthesized);
}
impl Schema {
pub fn new(mut tables: Vec<Table>) -> Result<Self, SchemaError> {
for table in &mut tables {
synthesize_unique_from_indexes(table);
}
let mut by_name = HashMap::with_capacity(tables.len());
let mut by_id = HashMap::with_capacity(tables.len());
for (idx, table) in tables.iter().enumerate() {
if by_name.contains_key(&table.name) {
return Err(SchemaError::DuplicateTableName(table.name.clone()));
}
if by_id.contains_key(&table.id) {
return Err(SchemaError::DuplicateTableId(table.id));
}
by_name.insert(table.name.clone(), idx);
by_id.insert(table.id, idx);
}
for table in &tables {
Self::validate_table(table, &by_name)?;
}
Ok(Self {
tables,
by_name,
by_id,
})
}
fn validate_table(
table: &Table,
table_names: &HashMap<String, usize>,
) -> Result<(), SchemaError> {
let mut column_names = HashMap::with_capacity(table.columns.len());
let mut column_ids = HashMap::with_capacity(table.columns.len());
for col in &table.columns {
if column_names.contains_key(&col.name) {
return Err(SchemaError::DuplicateColumnName(
table.name.clone(),
col.name.clone(),
));
}
if column_ids.contains_key(&col.id) {
return Err(SchemaError::DuplicateColumnId(table.name.clone(), col.id));
}
if col.embedding_source.is_some() && col.storage_type != ColumnType::Embedding {
return Err(SchemaError::EmbeddingSourceOnNonEmbedding(
table.name.clone(),
col.name.clone(),
));
}
if matches!(
col.embedding_source,
Some(
EmbeddingSource::LocalModel { .. }
| EmbeddingSource::ConfiguredModel { .. }
| EmbeddingSource::GeneratedColumn { .. }
| EmbeddingSource::GeneratedColumnSpec { .. }
)
) && col.embedding_dim.unwrap_or(0) == 0
{
return Err(SchemaError::EmbeddingSourceMissingDim(
table.name.clone(),
col.name.clone(),
));
}
column_names.insert(col.name.clone(), col.id);
column_ids.insert(col.id, col.name.clone());
}
for col in &table.columns {
if let Some(EmbeddingSource::ConfiguredModel {
provider_id,
model_id,
model_version,
}) = col.embedding_source.as_ref()
{
if provider_id.is_empty() || model_id.is_empty() || model_version.is_empty() {
return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
table.name.clone(),
col.name.clone(),
"provider, model, and version are required".into(),
));
}
}
}
for col in &table.columns {
let Some(EmbeddingSource::GeneratedColumnSpec { spec }) = col.embedding_source.as_ref()
else {
continue;
};
if spec.provider_id.is_empty()
|| spec.model_id.is_empty()
|| spec.model_version.is_empty()
|| spec.source_columns.is_empty()
{
return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
table.name.clone(),
col.name.clone(),
"provider, model, version, and source columns are required".into(),
));
}
if col.embedding_dim != Some(spec.dimension) {
return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
table.name.clone(),
col.name.clone(),
"spec dimension must match embedding_dim".into(),
));
}
let mut seen = HashSet::new();
if spec.source_columns.iter().any(|source_id| {
*source_id == col.id
|| !seen.insert(*source_id)
|| !column_ids.contains_key(source_id)
}) {
return Err(SchemaError::InvalidGeneratedEmbeddingSpec(
table.name.clone(),
col.name.clone(),
"source columns must exist, be unique, and exclude the target".into(),
));
}
}
for pk in &table.primary_key {
if !column_names.contains_key(pk) {
return Err(SchemaError::MissingPrimaryKeyColumn(
table.name.clone(),
pk.clone(),
));
}
}
for idx in &table.indexes {
for col in &idx.columns {
if !column_names.contains_key(col) {
return Err(SchemaError::MissingIndexColumn(
table.name.clone(),
idx.name.clone(),
col.clone(),
));
}
}
}
for uq in &table.unique_constraints {
for col in &uq.columns {
if !column_names.contains_key(col) {
return Err(SchemaError::MissingUniqueColumn(
table.name.clone(),
uq.name.clone(),
col.clone(),
));
}
}
}
for fk in &table.foreign_keys {
for col in &fk.columns {
if !column_names.contains_key(col) {
return Err(SchemaError::MissingForeignKeyColumn(
table.name.clone(),
fk.name.clone(),
col.clone(),
));
}
}
if !table_names.contains_key(&fk.references_table) {
return Err(SchemaError::MissingReferencedTable(
table.name.clone(),
fk.name.clone(),
fk.references_table.clone(),
));
}
}
Ok(())
}
pub fn table(&self, name: &str) -> Option<&Table> {
self.by_name.get(name).map(|&idx| &self.tables[idx])
}
pub fn table_by_id(&self, id: u32) -> Option<&Table> {
self.by_id.get(&id).map(|&idx| &self.tables[idx])
}
pub fn has_table(&self, name: &str) -> bool {
self.by_name.contains_key(name)
}
pub fn rename_table(&mut self, from: &str, to: &str) -> bool {
if from == to {
return self.has_table(from);
}
if !self.has_table(from) || self.has_table(to) {
return false;
}
let idx = *self.by_name.get(from).unwrap();
self.tables[idx].name = to.to_string();
self.by_name.remove(from);
self.by_name.insert(to.to_string(), idx);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_table(name: &str, id: u32) -> Table {
Table {
id,
name: name.into(),
columns: vec![Column::new(1, "id", ColumnType::Int64)],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}
}
#[test]
fn schema_rejects_duplicate_table_name() {
let err = Schema::new(vec![make_table("a", 1), make_table("a", 2)]).unwrap_err();
assert!(matches!(err, SchemaError::DuplicateTableName(n) if n == "a"));
}
#[test]
fn schema_rejects_duplicate_table_id() {
let err = Schema::new(vec![make_table("a", 1), make_table("b", 1)]).unwrap_err();
assert!(matches!(err, SchemaError::DuplicateTableId(1)));
}
#[test]
fn schema_rejects_missing_pk_column() {
let t = Table {
id: 1,
name: "t".into(),
columns: vec![Column::new(1, "x", ColumnType::Text)],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
};
let err = Schema::new(vec![t]).unwrap_err();
assert!(matches!(err, SchemaError::MissingPrimaryKeyColumn(_, _)));
}
#[test]
fn unique_index_synthesizes_unique_constraint() {
let schema = Schema::new(vec![Table {
id: 1,
name: "users".into(),
columns: vec![
Column::new(1, "id", ColumnType::Int64),
Column::new(2, "email", ColumnType::Text),
Column::new(3, "handle", ColumnType::Text),
],
primary_key: vec!["id".into()],
indexes: vec![
Index {
name: "idx_email".into(),
columns: vec!["email".into()],
unique: true,
kind: Default::default(),
ann_quantization: Default::default(),
..Default::default()
},
Index {
name: "idx_handle".into(),
columns: vec!["handle".into()],
unique: false,
kind: Default::default(),
ann_quantization: Default::default(),
..Default::default()
},
],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}])
.unwrap();
let table = schema.table("users").unwrap();
assert_eq!(table.unique_constraints.len(), 1);
assert_eq!(
table.unique_constraints[0].columns,
vec!["email".to_string()]
);
}
#[test]
fn unique_index_does_not_duplicate_existing_constraint() {
let schema = Schema::new(vec![Table {
id: 1,
name: "users".into(),
columns: vec![
Column::new(1, "id", ColumnType::Int64),
Column::new(2, "email", ColumnType::Text),
],
primary_key: vec!["id".into()],
indexes: vec![Index {
name: "idx_email".into(),
columns: vec!["email".into()],
unique: true,
kind: Default::default(),
ann_quantization: Default::default(),
..Default::default()
}],
foreign_keys: vec![],
unique_constraints: vec![UniqueConstraint {
name: "uq_email".into(),
columns: vec!["email".into()],
}],
check_constraints: vec![],
}])
.unwrap();
let table = schema.table("users").unwrap();
assert_eq!(table.unique_constraints.len(), 1);
assert_eq!(table.unique_constraints[0].name, "uq_email");
}
#[test]
fn schema_roundtrips_json() {
let schema = Schema::new(vec![Table {
id: 1,
name: "users".into(),
columns: vec![
Column::new(1, "id", ColumnType::Int64),
Column {
nullable: true,
..Column::new(2, "email", ColumnType::Text)
},
],
primary_key: vec!["id".into()],
indexes: vec![Index {
name: "idx_email".into(),
columns: vec!["email".into()],
unique: true,
kind: Default::default(),
ann_quantization: Default::default(),
..Default::default()
}],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![CheckConstraint {
name: "chk_id_positive".into(),
expr: "id > 0".into(),
}],
}])
.unwrap();
let json = serde_json::to_string(&schema).unwrap();
let decoded: Schema = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.tables.len(), 1);
assert_eq!(decoded.table("users").unwrap().columns.len(), 2);
}
#[test]
fn dense_ann_quantization_roundtrips_and_old_json_defaults_binary() {
let dense = Index {
name: "idx_embedding".into(),
columns: vec!["embedding".into()],
unique: false,
kind: IndexKind::Ann,
ann_quantization: AnnQuantization::Dense,
predicate: Some("embedding IS NOT NULL".into()),
ann_m: Some(24),
ann_ef_construction: Some(96),
ann_ef_search: Some(48),
..Default::default()
};
let json = serde_json::to_value(&dense).unwrap();
assert_eq!(json["ann_quantization"], "dense");
assert_eq!(json["ann_m"], 24);
assert_eq!(json["predicate"], "embedding IS NOT NULL");
assert_eq!(
serde_json::from_value::<Index>(json)
.unwrap()
.ann_quantization,
AnnQuantization::Dense
);
let old: Index = serde_json::from_value(serde_json::json!({
"name": "idx_embedding",
"columns": ["embedding"],
"unique": false,
"kind": "ann"
}))
.unwrap();
assert_eq!(old.ann_quantization, AnnQuantization::BinarySign);
}
#[test]
fn swappable_ann_algorithm_and_product_quantization_roundtrip() {
let diskann = Index {
name: "idx_diskann".into(),
columns: vec!["embedding".into()],
unique: false,
kind: IndexKind::Ann,
ann_algorithm: AnnAlgorithm::DiskAnn,
ann_quantization: AnnQuantization::Dense,
ann_diskann_r: Some(128),
ann_diskann_l: Some(256),
ann_diskann_beam_width: Some(4),
ann_diskann_alpha: Some(130),
..Default::default()
};
let json = serde_json::to_value(&diskann).unwrap();
assert_eq!(json["ann_algorithm"], "diskann");
assert_eq!(json["ann_diskann_r"], 128);
assert_eq!(json["ann_diskann_alpha"], 130);
let decoded: Index = serde_json::from_value(json).unwrap();
assert_eq!(decoded.ann_algorithm, AnnAlgorithm::DiskAnn);
assert_eq!(decoded.ann_diskann_l, Some(256));
let ivf = Index {
name: "idx_ivf".into(),
columns: vec!["embedding".into()],
kind: IndexKind::Ann,
ann_algorithm: AnnAlgorithm::Ivf,
ann_quantization: AnnQuantization::Dense,
ann_ivf_nlist: Some(512),
ann_ivf_nprobe: Some(16),
ann_ivf_training_samples: Some(20_000),
..Default::default()
};
let json = serde_json::to_value(&ivf).unwrap();
assert_eq!(json["ann_algorithm"], "ivf");
assert_eq!(json["ann_ivf_nlist"], 512);
assert_eq!(json["ann_ivf_training_samples"], 20_000);
let pq = Index {
name: "idx_pq".into(),
columns: vec!["embedding".into()],
kind: IndexKind::Ann,
ann_quantization: AnnQuantization::Product {
num_subvectors: 32,
bits: 8,
},
ann_pq_training_samples: Some(10_000),
ann_pq_seed: Some(42),
ann_pq_rerank_factor: Some(3),
..Default::default()
};
let json = serde_json::to_value(&pq).unwrap();
assert_eq!(json["ann_quantization"]["product"]["num_subvectors"], 32);
assert_eq!(json["ann_quantization"]["product"]["bits"], 8);
assert_eq!(json["ann_pq_seed"], 42);
let decoded: Index = serde_json::from_value(json).unwrap();
assert_eq!(
decoded.ann_quantization,
AnnQuantization::Product {
num_subvectors: 32,
bits: 8
}
);
let hnsw = Index {
name: "idx_hnsw".into(),
columns: vec!["embedding".into()],
kind: IndexKind::Ann,
ann_quantization: AnnQuantization::Dense,
..Default::default()
};
let json = serde_json::to_value(&hnsw).unwrap();
assert_eq!(json["ann_algorithm"], "hnsw");
}
#[test]
fn embedding_source_roundtrips_json() {
let mut emb = Column::new(2, "vec", ColumnType::Embedding);
emb.embedding_dim = Some(4);
emb.embedding_source = Some(EmbeddingSource::LocalModel {
model_path: "/models/kit-mini".into(),
model_id: "kit-mini".into(),
});
let schema = Schema::new(vec![Table {
id: 1,
name: "docs".into(),
columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}])
.unwrap();
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("local_model"));
assert!(json.contains("kit-mini"));
let decoded: Schema = serde_json::from_str(&json).unwrap();
let col = decoded.table("docs").unwrap().column("vec").unwrap();
assert_eq!(
col.embedding_source,
Some(EmbeddingSource::LocalModel {
model_path: "/models/kit-mini".into(),
model_id: "kit-mini".into(),
})
);
}
#[test]
fn embedding_source_rejected_on_non_embedding() {
let mut col = Column::new(2, "name", ColumnType::Text);
col.embedding_source = Some(EmbeddingSource::SuppliedByApplication);
let err = Schema::new(vec![Table {
id: 1,
name: "t".into(),
columns: vec![Column::new(1, "id", ColumnType::Int64), col],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}])
.unwrap_err();
assert!(matches!(
err,
SchemaError::EmbeddingSourceOnNonEmbedding(_, _)
));
}
#[test]
fn generated_embedding_requires_dim() {
let mut emb = Column::new(2, "vec", ColumnType::Embedding);
emb.embedding_source = Some(EmbeddingSource::GeneratedColumn {
provider: "local-test".into(),
});
let err = Schema::new(vec![Table {
id: 1,
name: "t".into(),
columns: vec![Column::new(1, "id", ColumnType::Int64), emb],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}])
.unwrap_err();
assert!(matches!(err, SchemaError::EmbeddingSourceMissingDim(_, _)));
}
#[test]
fn generated_embedding_spec_roundtrips_and_validates_sources() {
let mut emb = Column::new(3, "vec", ColumnType::Embedding);
emb.embedding_dim = Some(4);
emb.embedding_source = Some(EmbeddingSource::GeneratedColumnSpec {
spec: GeneratedEmbeddingSpec {
provider_id: "provider".into(),
model_id: "model".into(),
model_version: "1".into(),
source_columns: vec![2],
input_template: "{body}".into(),
dimension: 4,
normalization: EmbeddingSpecNormalization::None,
failure_policy: EmbeddingWriteFailurePolicy::AbortWrite,
},
});
let schema = Schema::new(vec![Table {
id: 1,
name: "docs".into(),
columns: vec![
Column::new(1, "id", ColumnType::Int64),
Column::new(2, "body", ColumnType::Text),
emb,
],
primary_key: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
unique_constraints: vec![],
check_constraints: vec![],
}])
.unwrap();
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("generated_column_spec"));
assert_eq!(serde_json::from_str::<Schema>(&json).unwrap(), schema);
}
}