use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Result, SynaError};
use super::registry::{MigrationOp, SchemaMigration};
use super::schema::{FeatureSchema, FeatureType};
pub fn apply_migration(schema: &mut FeatureSchema, migration: &SchemaMigration) -> Result<()> {
for op in &migration.operations {
validate_operation(schema, op)?;
}
for op in &migration.operations {
apply_operation(schema, op)?;
}
schema.version = migration.version;
Ok(())
}
fn validate_operation(schema: &FeatureSchema, op: &MigrationOp) -> Result<()> {
match op {
MigrationOp::AddColumn(col_def) => {
if schema.columns.iter().any(|c| c.name == col_def.name) {
return Err(SynaError::InvalidInput(format!(
"Column '{}' already exists in schema '{}'",
col_def.name, schema.name
)));
}
Ok(())
}
MigrationOp::WidenType { column, from, to } => {
let col = schema.columns.iter().find(|c| c.name == *column);
let col = col.ok_or_else(|| {
SynaError::InvalidInput(format!(
"Column '{}' not found in schema '{}'",
column, schema.name
))
})?;
if col.dtype != *from {
return Err(SynaError::InvalidInput(format!(
"Column '{}' has type {:?}, expected {:?}",
column, col.dtype, from
)));
}
if !is_widening(from, to) {
return Err(SynaError::InvalidInput(format!(
"Cannot narrow type from {:?} to {:?} for column '{}'",
from, to, column
)));
}
Ok(())
}
MigrationOp::DeprecateColumn { column } => {
if !schema.columns.iter().any(|c| c.name == *column) {
return Err(SynaError::InvalidInput(format!(
"Column '{}' not found in schema '{}'",
column, schema.name
)));
}
Ok(())
}
MigrationOp::UpdateDefault { column, .. } => {
if !schema.columns.iter().any(|c| c.name == *column) {
return Err(SynaError::InvalidInput(format!(
"Column '{}' not found in schema '{}'",
column, schema.name
)));
}
Ok(())
}
}
}
fn apply_operation(schema: &mut FeatureSchema, op: &MigrationOp) -> Result<()> {
match op {
MigrationOp::AddColumn(col_def) => {
schema.columns.push(col_def.clone());
Ok(())
}
MigrationOp::WidenType { column, to, .. } => {
if let Some(col) = schema.columns.iter_mut().find(|c| c.name == *column) {
col.dtype = to.clone();
}
Ok(())
}
MigrationOp::DeprecateColumn { column } => {
if let Some(col) = schema.columns.iter_mut().find(|c| c.name == *column) {
col.deprecated = true;
}
Ok(())
}
MigrationOp::UpdateDefault {
column,
new_default,
} => {
if let Some(col) = schema.columns.iter_mut().find(|c| c.name == *column) {
col.default = Some(new_default.clone());
}
Ok(())
}
}
}
fn is_widening(from: &FeatureType, to: &FeatureType) -> bool {
matches!((from, to), (FeatureType::Int64, FeatureType::Float64))
}
pub fn create_migration(
version: u32,
operations: Vec<MigrationOp>,
description: Option<String>,
) -> SchemaMigration {
let applied_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
SchemaMigration {
version,
operations,
applied_at,
description,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feature_store::schema::*;
fn base_schema() -> FeatureSchema {
FeatureSchema {
name: "test".to_string(),
columns: vec![
ColumnDef {
name: "id".to_string(),
dtype: FeatureType::String,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: true,
is_event_timestamp: false,
deprecated: false,
},
ColumnDef {
name: "ts".to_string(),
dtype: FeatureType::Timestamp,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: true,
deprecated: false,
},
ColumnDef {
name: "count".to_string(),
dtype: FeatureType::Int64,
default: Some(FeatureValue::Int64(0)),
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
},
],
version: 1,
description: None,
tags: vec![],
created_at: 0,
created_by: None,
}
}
#[test]
fn test_add_column() {
let mut schema = base_schema();
let migration = create_migration(
2,
vec![MigrationOp::AddColumn(ColumnDef {
name: "score".to_string(),
dtype: FeatureType::Float64,
default: Some(FeatureValue::Float64(0.0)),
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
})],
Some("Add score column".to_string()),
);
apply_migration(&mut schema, &migration).unwrap();
assert_eq!(schema.version, 2);
assert_eq!(schema.columns.len(), 4);
assert_eq!(schema.columns[3].name, "score");
}
#[test]
fn test_widen_type() {
let mut schema = base_schema();
let migration = create_migration(
2,
vec![MigrationOp::WidenType {
column: "count".to_string(),
from: FeatureType::Int64,
to: FeatureType::Float64,
}],
None,
);
apply_migration(&mut schema, &migration).unwrap();
assert_eq!(schema.columns[2].dtype, FeatureType::Float64);
}
#[test]
fn test_narrow_type_rejected() {
let mut schema = base_schema();
schema.columns[2].dtype = FeatureType::Float64;
let migration = create_migration(
2,
vec![MigrationOp::WidenType {
column: "count".to_string(),
from: FeatureType::Float64,
to: FeatureType::Int64,
}],
None,
);
assert!(apply_migration(&mut schema, &migration).is_err());
}
#[test]
fn test_deprecate_column() {
let mut schema = base_schema();
let migration = create_migration(
2,
vec![MigrationOp::DeprecateColumn {
column: "count".to_string(),
}],
None,
);
apply_migration(&mut schema, &migration).unwrap();
assert!(schema.columns[2].deprecated);
}
#[test]
fn test_update_default() {
let mut schema = base_schema();
let migration = create_migration(
2,
vec![MigrationOp::UpdateDefault {
column: "count".to_string(),
new_default: FeatureValue::Int64(42),
}],
None,
);
apply_migration(&mut schema, &migration).unwrap();
assert_eq!(schema.columns[2].default, Some(FeatureValue::Int64(42)));
}
#[test]
fn test_add_duplicate_column_rejected() {
let mut schema = base_schema();
let migration = create_migration(
2,
vec![MigrationOp::AddColumn(ColumnDef {
name: "count".to_string(), dtype: FeatureType::Float64,
default: None,
constraints: None,
ttl_seconds: None,
is_entity_key: false,
is_event_timestamp: false,
deprecated: false,
})],
None,
);
assert!(apply_migration(&mut schema, &migration).is_err());
}
}